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
+246
-115
@@ -1,79 +1,167 @@
|
||||
//! Generic pure-replay + CAS-write machinery shared by every aggregate
|
||||
//! type (`applicant`, `subscriber`, `project`) - one small `AggregateKind`
|
||||
//! impl per type supplies its own states/transition table/event-type
|
||||
//! names, this module supplies the mechanics once. Mirrors dodrenett's
|
||||
//! `lib/machines/*.ts` shape (a `Record<State, State[]>` table + a pure
|
||||
//! `replay()`), but generalized across types instead of copy-pasted per
|
||||
//! type, and with real optimistic concurrency on write (dodrenett had
|
||||
//! none - see `crate::events::store`).
|
||||
//! Generic pure-replay + CAS-write machinery for event-sourced
|
||||
//! aggregates - the state graph itself (what states exist, what
|
||||
//! event-type string represents each, what transitions are legal) is
|
||||
//! loaded at runtime from `questions/aggregates.yaml`, not compiled
|
||||
//! Rust. Content declares the graph; this module only knows how to
|
||||
//! replay/append against whatever graph it's handed
|
||||
//! (`content::load_aggregates_from_gitea`, hot-swapped in
|
||||
//! `AppState.aggregates` the same way `AppState.questions` is).
|
||||
//!
|
||||
//! `AggregateSchema.bucket` doubles as both the KV bucket name
|
||||
//! (`answers.rs`'s read-model projection) and the JetStream
|
||||
//! event-subject segment (`events::store`'s `events.<bucket>.<id>`) -
|
||||
//! one name, not two, since content only ever had one name for this to
|
||||
//! begin with.
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
pub mod applicant;
|
||||
pub mod organization;
|
||||
pub mod project;
|
||||
pub mod subscriber;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::events::store::{append_event, load_events, Event};
|
||||
|
||||
/// A state machine's shape: its states, its transition table, and how
|
||||
/// states map to/from the event-log's `event_type` strings. Kept as a
|
||||
/// trait (not a generic enum param alone) so each aggregate module can
|
||||
/// own its own `State` enum and transition table as plain, readable
|
||||
/// Rust, not a shared data structure everyone has to thread through.
|
||||
pub trait AggregateKind: Copy + Clone + PartialEq + Eq + std::fmt::Debug + 'static {
|
||||
/// The bucket-name -> aggregate-type key used both for `events.rs`'s
|
||||
/// subject scheme and for `content.rs`'s load-time transition
|
||||
/// validation (see `content::aggregate_type_for_bucket`).
|
||||
const AGGREGATE_TYPE: &'static str;
|
||||
/// The state a brand-new aggregate starts in - also the state a
|
||||
/// creation event (see `create`) asserts.
|
||||
const INITIAL_STATE: Self;
|
||||
/// One aggregate type's full state graph, already validated (see
|
||||
/// `parse_aggregates_yaml`) - `initial` is guaranteed to be a key of
|
||||
/// `event_for_state`/`state_for_event`, every `transitions` key and
|
||||
/// target is guaranteed to be a declared state, and every state's
|
||||
/// event-type string is guaranteed unique within this schema.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AggregateSchema {
|
||||
pub bucket: String,
|
||||
pub initial: String,
|
||||
pub event_for_state: HashMap<String, String>,
|
||||
pub state_for_event: HashMap<String, String>,
|
||||
pub transitions: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Maps an event-log `event_type` string to the state it represents
|
||||
/// - `None` for anything this aggregate type doesn't recognize
|
||||
/// (replay skips it, never errors).
|
||||
fn from_event_type(event_type: &str) -> Option<Self>;
|
||||
/// The event-log `event_type` string that represents transitioning
|
||||
/// *into* this state.
|
||||
fn event_type(self) -> &'static str;
|
||||
/// States reachable directly from this one. Checked at write time
|
||||
/// (`transition`); replay is deliberately more lenient (see below).
|
||||
fn allowed(self) -> &'static [Self];
|
||||
impl AggregateSchema {
|
||||
pub fn has_state(&self, state: &str) -> bool {
|
||||
self.event_for_state.contains_key(state)
|
||||
}
|
||||
|
||||
pub fn allowed(&self, state: &str) -> &[String] {
|
||||
self.transitions
|
||||
.get(state)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawState {
|
||||
event: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawAggregateSchema {
|
||||
bucket: String,
|
||||
initial: String,
|
||||
states: HashMap<String, RawState>,
|
||||
#[serde(default)]
|
||||
transitions: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AggregatesFile {
|
||||
aggregates: Vec<RawAggregateSchema>,
|
||||
}
|
||||
|
||||
/// Parses and validates `aggregates.yaml`'s full contents. A malformed
|
||||
/// entry (an `initial`/`transitions` key or target that isn't a
|
||||
/// declared state, a duplicate `bucket`, or two states sharing one
|
||||
/// event-type string) is rejected here with a clear message - the same
|
||||
/// "bad content never silently corrupts a read/write" policy
|
||||
/// `content::validate_questions` already applies to pages.
|
||||
pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, AggregateSchema>> {
|
||||
let file: AggregatesFile =
|
||||
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing aggregates.yaml: {e}"))?;
|
||||
|
||||
let mut out = HashMap::new();
|
||||
for raw_schema in file.aggregates {
|
||||
let bucket = raw_schema.bucket.clone();
|
||||
if out.contains_key(&bucket) {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?} declared more than once");
|
||||
}
|
||||
if !raw_schema.states.contains_key(&raw_schema.initial) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: initial state {:?} is not declared in states",
|
||||
raw_schema.initial
|
||||
);
|
||||
}
|
||||
|
||||
let mut event_for_state = HashMap::new();
|
||||
let mut state_for_event = HashMap::new();
|
||||
for (state, raw_state) in &raw_schema.states {
|
||||
if let Some(existing) = state_for_event.insert(raw_state.event.clone(), state.clone())
|
||||
{
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: event {:?} used by both state {:?} and {:?}",
|
||||
raw_state.event, existing, state
|
||||
);
|
||||
}
|
||||
event_for_state.insert(state.clone(), raw_state.event.clone());
|
||||
}
|
||||
|
||||
for (from, targets) in &raw_schema.transitions {
|
||||
if !raw_schema.states.contains_key(from) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: transitions declared from undeclared state {from:?}"
|
||||
);
|
||||
}
|
||||
for to in targets {
|
||||
if !raw_schema.states.contains_key(to) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: transition {from:?} -> {to:?} targets an undeclared state"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.insert(
|
||||
bucket.clone(),
|
||||
AggregateSchema {
|
||||
bucket,
|
||||
initial: raw_schema.initial,
|
||||
event_for_state,
|
||||
state_for_event,
|
||||
transitions: raw_schema.transitions,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One aggregate's current view, replayed from its event history.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Aggregate<K> {
|
||||
pub struct Aggregate {
|
||||
pub id: String,
|
||||
pub state: K,
|
||||
pub state: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub sequence: u64,
|
||||
}
|
||||
|
||||
/// Pure, no IO, never panics. An event that doesn't fit the transition
|
||||
/// table from wherever replay has gotten to so far is silently skipped
|
||||
/// - not an error, not even the *first* recognized event has anything
|
||||
/// to be "from" yet, so it's always accepted. This lenience (not
|
||||
/// enforcing the transition table during replay, only at write time -
|
||||
/// see `transition` below) is deliberate and matches dodrenett's proven
|
||||
/// "self-healing against malformed history" policy: a corrupted or
|
||||
/// out-of-order event never crashes a read, it's just ignored.
|
||||
pub fn replay<K: AggregateKind>(id: &str, events: &[Event]) -> Option<Aggregate<K>> {
|
||||
let mut current: Option<Aggregate<K>> = None;
|
||||
/// Pure, no IO, never panics. An event whose type isn't declared in
|
||||
/// `schema`, or that doesn't fit the transition table from wherever
|
||||
/// replay has gotten to so far, is silently skipped - not an error,
|
||||
/// not even the *first* recognized event has anything to be "from" yet
|
||||
/// so it's always accepted. This lenience (not enforcing the
|
||||
/// transition table during replay, only at write time - see
|
||||
/// `transition` below) is deliberate: a corrupted or out-of-order
|
||||
/// event never crashes a read, it's just ignored.
|
||||
pub fn replay(schema: &AggregateSchema, id: &str, events: &[Event]) -> Option<Aggregate> {
|
||||
let mut current: Option<Aggregate> = None;
|
||||
for event in events {
|
||||
let Some(next) = K::from_event_type(&event.event_type) else {
|
||||
let Some(next) = schema.state_for_event.get(&event.event_type) else {
|
||||
continue;
|
||||
};
|
||||
let accept = match ¤t {
|
||||
None => true,
|
||||
Some(agg) => next == agg.state || agg.state.allowed().contains(&next),
|
||||
Some(agg) => *next == agg.state || schema.allowed(&agg.state).iter().any(|s| s == next),
|
||||
};
|
||||
if !accept {
|
||||
continue;
|
||||
}
|
||||
current = Some(Aggregate {
|
||||
id: id.to_string(),
|
||||
state: next,
|
||||
state: next.clone(),
|
||||
payload: event.payload.clone(),
|
||||
sequence: event.sequence,
|
||||
});
|
||||
@@ -122,17 +210,22 @@ fn classify_publish_err(e: async_nats::jetstream::context::PublishError) -> Tran
|
||||
/// create-only-once guard - a double-submit race on the same id fails
|
||||
/// the same way a disallowed transition does, rather than silently
|
||||
/// overwriting.
|
||||
pub async fn create<K: AggregateKind>(
|
||||
pub async fn create(
|
||||
js: &async_nats::jetstream::Context,
|
||||
schema: &AggregateSchema,
|
||||
id: &str,
|
||||
payload: serde_json::Value,
|
||||
occurred_at_ms: i64,
|
||||
) -> Result<Aggregate<K>, TransitionError> {
|
||||
) -> Result<Aggregate, TransitionError> {
|
||||
let event_type = schema
|
||||
.event_for_state
|
||||
.get(&schema.initial)
|
||||
.expect("schema.initial is validated to have an event mapping");
|
||||
let ack = append_event(
|
||||
js,
|
||||
K::AGGREGATE_TYPE,
|
||||
&schema.bucket,
|
||||
id,
|
||||
K::INITIAL_STATE.event_type(),
|
||||
event_type,
|
||||
payload.clone(),
|
||||
occurred_at_ms,
|
||||
Some(0),
|
||||
@@ -141,7 +234,7 @@ pub async fn create<K: AggregateKind>(
|
||||
.map_err(classify_publish_err)?;
|
||||
Ok(Aggregate {
|
||||
id: id.to_string(),
|
||||
state: K::INITIAL_STATE,
|
||||
state: schema.initial.clone(),
|
||||
payload,
|
||||
sequence: ack.sequence,
|
||||
})
|
||||
@@ -150,28 +243,35 @@ pub async fn create<K: AggregateKind>(
|
||||
/// Loads and replays `id`'s current state, then - if `target` is
|
||||
/// actually reachable from it - appends the corresponding event with
|
||||
/// the aggregate's just-replayed sequence as the CAS guard.
|
||||
pub async fn transition<K: AggregateKind>(
|
||||
pub async fn transition(
|
||||
js: &async_nats::jetstream::Context,
|
||||
schema: &AggregateSchema,
|
||||
id: &str,
|
||||
target: K,
|
||||
target: &str,
|
||||
payload: serde_json::Value,
|
||||
occurred_at_ms: i64,
|
||||
) -> Result<Aggregate<K>, TransitionError> {
|
||||
let events = load_events(js, K::AGGREGATE_TYPE, id)
|
||||
) -> Result<Aggregate, TransitionError> {
|
||||
let events = load_events(js, &schema.bucket, id)
|
||||
.await
|
||||
.map_err(|e| TransitionError::Store(e.to_string()))?;
|
||||
let current = replay::<K>(id, &events).ok_or(TransitionError::UnknownAggregate)?;
|
||||
if !current.state.allowed().contains(&target) {
|
||||
let current = replay(schema, id, &events).ok_or(TransitionError::UnknownAggregate)?;
|
||||
if !schema.allowed(¤t.state).iter().any(|s| s == target) {
|
||||
return Err(TransitionError::DisallowedTransition {
|
||||
from: format!("{:?}", current.state),
|
||||
to: format!("{target:?}"),
|
||||
from: current.state.clone(),
|
||||
to: target.to_string(),
|
||||
});
|
||||
}
|
||||
// Validated by parse_aggregates_yaml: every transitions target is a
|
||||
// declared state, so this is always Some given the check above.
|
||||
let event_type = schema
|
||||
.event_for_state
|
||||
.get(target)
|
||||
.expect("transition target is validated to be a declared state");
|
||||
let ack = append_event(
|
||||
js,
|
||||
K::AGGREGATE_TYPE,
|
||||
&schema.bucket,
|
||||
id,
|
||||
target.event_type(),
|
||||
event_type,
|
||||
payload.clone(),
|
||||
occurred_at_ms,
|
||||
Some(current.sequence),
|
||||
@@ -180,7 +280,7 @@ pub async fn transition<K: AggregateKind>(
|
||||
.map_err(classify_publish_err)?;
|
||||
Ok(Aggregate {
|
||||
id: id.to_string(),
|
||||
state: target,
|
||||
state: target.to_string(),
|
||||
payload,
|
||||
sequence: ack.sequence,
|
||||
})
|
||||
@@ -189,40 +289,24 @@ pub async fn transition<K: AggregateKind>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::store::Event;
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
enum TestState {
|
||||
Open,
|
||||
A,
|
||||
B,
|
||||
}
|
||||
|
||||
impl AggregateKind for TestState {
|
||||
const AGGREGATE_TYPE: &'static str = "test";
|
||||
const INITIAL_STATE: Self = TestState::Open;
|
||||
|
||||
fn from_event_type(event_type: &str) -> Option<Self> {
|
||||
match event_type {
|
||||
"opened" => Some(TestState::Open),
|
||||
"a" => Some(TestState::A),
|
||||
"b" => Some(TestState::B),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn event_type(self) -> &'static str {
|
||||
match self {
|
||||
TestState::Open => "opened",
|
||||
TestState::A => "a",
|
||||
TestState::B => "b",
|
||||
}
|
||||
}
|
||||
fn allowed(self) -> &'static [Self] {
|
||||
match self {
|
||||
TestState::Open => &[TestState::A, TestState::B],
|
||||
TestState::A | TestState::B => &[],
|
||||
}
|
||||
}
|
||||
fn test_schema() -> AggregateSchema {
|
||||
parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: test
|
||||
initial: open
|
||||
states:
|
||||
open: { event: opened }
|
||||
a: { event: a }
|
||||
b: { event: b }
|
||||
transitions:
|
||||
open: [a, b]
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.remove("test")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn event(seq: u64, event_type: &str) -> Event {
|
||||
@@ -239,31 +323,83 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_history_is_no_aggregate() {
|
||||
assert!(replay::<TestState>("x", &[]).is_none());
|
||||
let schema = test_schema();
|
||||
assert!(replay(&schema, "x", &[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_path_replays() {
|
||||
let schema = test_schema();
|
||||
let events = vec![event(1, "opened"), event(2, "a")];
|
||||
let agg = replay::<TestState>("x", &events).unwrap();
|
||||
assert_eq!(agg.state, TestState::A);
|
||||
let agg = replay(&schema, "x", &events).unwrap();
|
||||
assert_eq!(agg.state, "a");
|
||||
assert_eq!(agg.sequence, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disallowed_edge_is_skipped_not_fatal() {
|
||||
// "a" then "b" - B isn't reachable from A, so the second event
|
||||
// "a" then "b" - b isn't reachable from a, so the second event
|
||||
// is silently ignored, not an error.
|
||||
let schema = test_schema();
|
||||
let events = vec![event(1, "opened"), event(2, "a"), event(3, "b")];
|
||||
let agg = replay::<TestState>("x", &events).unwrap();
|
||||
assert_eq!(agg.state, TestState::A);
|
||||
let agg = replay(&schema, "x", &events).unwrap();
|
||||
assert_eq!(agg.state, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecognized_event_type_is_skipped() {
|
||||
let schema = test_schema();
|
||||
let events = vec![event(1, "opened"), event(2, "bogus"), event(3, "a")];
|
||||
let agg = replay::<TestState>("x", &events).unwrap();
|
||||
assert_eq!(agg.state, TestState::A);
|
||||
let agg = replay(&schema, "x", &events).unwrap();
|
||||
assert_eq!(agg.state, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_state_not_declared_is_rejected() {
|
||||
let err = parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: bad
|
||||
initial: missing
|
||||
states:
|
||||
open: { event: opened }
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("initial state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_target_not_declared_is_rejected() {
|
||||
let err = parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: bad
|
||||
initial: open
|
||||
states:
|
||||
open: { event: opened }
|
||||
transitions:
|
||||
open: [nonexistent]
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("undeclared state"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_event_type_is_rejected() {
|
||||
let err = parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: bad
|
||||
initial: open
|
||||
states:
|
||||
open: { event: same }
|
||||
other: { event: same }
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("used by both"));
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
@@ -274,20 +410,15 @@ mod tests {
|
||||
0..30,
|
||||
)
|
||||
) {
|
||||
let schema = test_schema();
|
||||
let events: Vec<Event> = types
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| event(i as u64, t))
|
||||
.collect();
|
||||
let result = replay::<TestState>("x", &events);
|
||||
let result = replay(&schema, "x", &events);
|
||||
if let Some(agg) = result {
|
||||
// Whatever state we landed in must itself be a real
|
||||
// state this machine declares - trivially true here
|
||||
// since TestState::allowed() only ever returns TestState
|
||||
// values, but this is the shape the real aggregates'
|
||||
// proptests repeat: replaying arbitrary event-type noise
|
||||
// must never produce anything but a legitimate state.
|
||||
let _: TestState = agg.state;
|
||||
assert!(schema.has_state(&agg.state));
|
||||
assert!(agg.sequence <= events.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user