Make aggregate state graphs content-driven, not compiled Rust
Deploy / deploy (push) Successful in 59s
Deploy / deploy (push) Successful in 59s
Six hand-maintained copies of the bucket->aggregate-type table (4 per-type State enums, content::aggregate_type_for_bucket + is_valid_transition_target, answers.rs's two dispatch matches, backfill_events.rs's own migration table) collapse into one: questions/aggregates.yaml, loaded and hot-swapped in AppState.aggregates the same way AppState.questions already is. aggregates/mod.rs's replay/create/transition now run off a runtime-loaded AggregateSchema instead of a compile-time AggregateKind trait impl per type - the CAS/JetStream mechanics underneath are unchanged. A new aggregate type is now a content-only change, no portal deploy required. backfill_events.rs is deleted outright rather than ported - its migration job was already done and production data here is minimal and expendable (the EVENTS stream gets purged by hand post-deploy). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1c1cbfc13b
commit
84c3fad339
+31
-82
@@ -1,15 +1,16 @@
|
||||
//! Read-model storage for submitted answers - the KV-backed projection
|
||||
//! of the durable event log now underneath it (`events::store`,
|
||||
//! `aggregates`) for the three buckets migrated onto that machinery
|
||||
//! this pass (`content::aggregate_type_for_bucket`). An
|
||||
//! `Alternative.record_as`/`ResourceSpec`'s bucket is still just a
|
||||
//! bucket name as far as `resource::get_resource` is concerned - this
|
||||
//! file is where that name additionally gets checked against a real,
|
||||
//! Rust-defined transition table, for the buckets that have one.
|
||||
//! A bucket outside that three-entry map still works exactly as
|
||||
//! before (direct KV mutate-in-place, no event log, no CAS) - this is
|
||||
//! deliberately not a hard cutover, so content isn't forced to migrate
|
||||
//! before it needs to.
|
||||
//! `aggregates`) for every bucket that has a declared state graph in
|
||||
//! `AppState.aggregates` (loaded from `aggregates.yaml` - see
|
||||
//! `content::load_aggregates_from_gitea`). An `Alternative.record_as`/
|
||||
//! `ResourceSpec`'s bucket is still just a bucket name as far as
|
||||
//! `resource::get_resource` is concerned - this file is where that name
|
||||
//! additionally gets checked against the real, content-declared
|
||||
//! transition table, for the buckets that have one. A bucket with no
|
||||
//! entry in `aggregates` still works exactly as before (direct KV
|
||||
//! mutate-in-place, no event log, no CAS) - this is deliberately not a
|
||||
//! hard cutover, so content isn't forced to declare a state graph
|
||||
//! before it needs one.
|
||||
|
||||
use leptos::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -41,8 +42,10 @@ pub struct Answer {
|
||||
/// there's no fixed, pre-declared list of buckets, since content can
|
||||
/// name any.
|
||||
#[cfg(feature = "ssr")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn store_answer(
|
||||
js: &async_nats::jetstream::Context,
|
||||
aggregates: &std::collections::HashMap<String, crate::aggregates::AggregateSchema>,
|
||||
bucket: &str,
|
||||
id: String,
|
||||
question_id: &str,
|
||||
@@ -73,28 +76,12 @@ pub async fn store_answer(
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
store.put(id.clone(), data.into()).await?;
|
||||
|
||||
// Also seed the event log for the three aggregate types migrated
|
||||
// onto it this pass - best-effort, same "a hiccup here shouldn't
|
||||
// fail a submission the NATS notification event has already
|
||||
// recorded" policy as the KV write above.
|
||||
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(bucket) {
|
||||
use crate::aggregates::{applicant, create, organization, project, subscriber};
|
||||
let seed = match agg_type {
|
||||
"applicant" => create::<applicant::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
"subscriber" => create::<subscriber::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
"project" => create::<project::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
"organization" => create::<organization::State>(js, &id, responses.clone(), submitted_ms)
|
||||
.await
|
||||
.map(|_| ()),
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(e) = seed {
|
||||
// Also seed the event log for any bucket that has a declared state
|
||||
// graph - best-effort, same "a hiccup here shouldn't fail a
|
||||
// submission the NATS notification event has already recorded"
|
||||
// policy as the KV write above.
|
||||
if let Some(schema) = aggregates.get(bucket) {
|
||||
if let Err(e) = crate::aggregates::create(js, schema, &id, responses.clone(), submitted_ms).await {
|
||||
tracing::error!(bucket, id, error = %e, "failed to seed event log for submission");
|
||||
}
|
||||
}
|
||||
@@ -187,15 +174,15 @@ pub async fn transition_answer(
|
||||
let decision_payload_for_event =
|
||||
serde_json::json!({ "to": to, "item": item_id, "by": user.username });
|
||||
|
||||
// For the three buckets migrated onto the event log this pass, the
|
||||
// real fix for the lost-update race two concurrent decisions on the
|
||||
// same item used to hit: append with CAS on the aggregate's
|
||||
// just-replayed sequence, so a second racing caller's write is
|
||||
// rejected instead of silently overwriting the first. A bucket
|
||||
// outside that map falls back to the direct KV mutate this always
|
||||
// did - not every resource has to be migrated to keep working.
|
||||
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(bucket) {
|
||||
transition_by_aggregate(&state.jetstream, agg_type, &item_id, &to, decision_payload_for_event.clone(), decided_ms)
|
||||
// For any bucket with a declared state graph, the real fix for the
|
||||
// lost-update race two concurrent decisions on the same item used to
|
||||
// hit: append with CAS on the aggregate's just-replayed sequence, so
|
||||
// a second racing caller's write is rejected instead of silently
|
||||
// overwriting the first. A bucket outside `aggregates` falls back to
|
||||
// the direct KV mutate this always did - not every resource has to
|
||||
// be event-sourced to keep working.
|
||||
if let Some(schema) = state.aggregates.load().get(bucket) {
|
||||
crate::aggregates::transition(&state.jetstream, schema, &item_id, &to, decision_payload_for_event.clone(), decided_ms)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
}
|
||||
@@ -231,44 +218,6 @@ pub async fn transition_answer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatches to the right aggregate type's `transition()` by name -
|
||||
/// the one place `answers.rs` has to know the fixed, three-entry set of
|
||||
/// migrated aggregate types (`content::aggregate_type_for_bucket`'s
|
||||
/// counterpart), since `crate::aggregates` itself stays generic over
|
||||
/// any `AggregateKind` and has no list of its own to dispatch through.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn transition_by_aggregate(
|
||||
js: &async_nats::jetstream::Context,
|
||||
aggregate_type: &str,
|
||||
item_id: &str,
|
||||
to: &str,
|
||||
payload: serde_json::Value,
|
||||
occurred_at_ms: i64,
|
||||
) -> Result<(), crate::aggregates::TransitionError> {
|
||||
use crate::aggregates::{applicant, organization, project, subscriber, transition, AggregateKind, TransitionError};
|
||||
|
||||
macro_rules! dispatch {
|
||||
($state:ty) => {{
|
||||
let target = <$state>::from_event_type(to).ok_or_else(|| {
|
||||
TransitionError::DisallowedTransition {
|
||||
from: "?".to_string(),
|
||||
to: to.to_string(),
|
||||
}
|
||||
})?;
|
||||
transition::<$state>(js, item_id, target, payload, occurred_at_ms).await?;
|
||||
}};
|
||||
}
|
||||
|
||||
match aggregate_type {
|
||||
"applicant" => dispatch!(applicant::State),
|
||||
"subscriber" => dispatch!(subscriber::State),
|
||||
"project" => dispatch!(project::State),
|
||||
"organization" => dispatch!(organization::State),
|
||||
_ => return Err(TransitionError::UnknownAggregate),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The self-service counterpart to `transition_answer`: no signed-in
|
||||
/// session, no group check - authorized instead by already holding
|
||||
/// `item_id` (a chain hash, opaque and unguessable) plus a matching
|
||||
@@ -329,10 +278,10 @@ pub async fn self_transition_answer(
|
||||
let decision_payload_for_event =
|
||||
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
|
||||
|
||||
if let Some(agg_type) = crate::content::aggregate_type_for_bucket(&self_transition.bucket) {
|
||||
transition_by_aggregate(
|
||||
if let Some(schema) = state.aggregates.load().get(&self_transition.bucket) {
|
||||
crate::aggregates::transition(
|
||||
&state.jetstream,
|
||||
agg_type,
|
||||
schema,
|
||||
&item_id,
|
||||
&self_transition.to,
|
||||
decision_payload_for_event.clone(),
|
||||
|
||||
Reference in New Issue
Block a user