Reseed lost event history from the KV projection on transition
Deploy / deploy (push) Successful in 1m0s

A record whose event log is empty (the EVENTS stream was purged, or
the record predates event-sourcing) but whose projection exists used
to be stranded - replay found nothing, every transition failed with
"unknown item". transition_or_reseed reconstructs a minimal one-event
history from the projection's own current state and retries, making
"zap the whole event stream" a recoverable operation by design instead
of a slow-motion data loss. Reseed races are guarded by the same
expected-sequence-0 CAS create uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 22:27:57 +02:00
co-authored by Claude Sonnet 5
parent 3233d9a3db
commit 20b24f08c2
2 changed files with 79 additions and 4 deletions
+46 -4
View File
@@ -154,6 +154,39 @@ pub async fn transition_answers(
}
}
/// `aggregates::transition`, with one recovery path: an aggregate the
/// event log has never heard of but whose KV projection exists (the
/// log was purged, or predates event-sourcing) gets a minimal history
/// reseeded from the projection's own current state, then the
/// transition retries. Keeps "zap the whole event stream" a safe
/// operation instead of one that strands every pre-wipe record.
#[cfg(feature = "ssr")]
async fn transition_or_reseed(
js: &async_nats::jetstream::Context,
schema: &crate::aggregates::AggregateSchema,
item_id: &str,
to: &str,
current_state: &str,
payload: serde_json::Value,
occurred_at_ms: i64,
) -> Result<(), crate::aggregates::TransitionError> {
match crate::aggregates::transition(js, schema, item_id, to, payload.clone(), occurred_at_ms)
.await
{
Err(crate::aggregates::TransitionError::UnknownAggregate) => {
tracing::warn!(
bucket = %schema.bucket, item_id, current_state,
"no event history for a projected record - reseeding from the projection"
);
crate::aggregates::reseed(js, schema, item_id, current_state, occurred_at_ms).await?;
crate::aggregates::transition(js, schema, item_id, to, payload, occurred_at_ms)
.await
.map(|_| ())
}
r => r.map(|_| ()),
}
}
/// Same alternative-scoped feature lookup as `get_resource`, and for
/// the same reason - a feature name isn't unique across a whole
/// question, only within its own alternative.
@@ -230,9 +263,17 @@ async fn apply_transition(
// 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.item_id, &item.to, decision_payload_for_event.clone(), decided_ms)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
transition_or_reseed(
&state.jetstream,
schema,
&item.item_id,
&item.to,
&answer.state,
decision_payload_for_event.clone(),
decided_ms,
)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
answer.state = item.to.clone();
@@ -327,11 +368,12 @@ pub async fn self_transition_answer(
serde_json::json!({ "to": self_transition.to, "item": item_id, "by": email });
if let Some(schema) = state.aggregates.load().get(&self_transition.bucket) {
crate::aggregates::transition(
transition_or_reseed(
&state.jetstream,
schema,
&item_id,
&self_transition.to,
&answer.state,
decision_payload_for_event.clone(),
decided_ms,
)