Skip to content

Commit 615d0c1

Browse files
authored
fix(engine): retire a terminal group's deferred-peel rows so they stop charging the account budget (#1784)
* fix(engine): retire a terminal group's deferred-peel rows so they stop charging the account budget When this device's copy of a group became terminal, its PeelDeferred rows were never retired. The sweep that releases such rows is reached only through prepare_convergence_input_advance, which refuses a removed copy outright and a disbanded one via EpochState::Disbanded, while ensure_peel_deferred_usage_initialized re-counts every group's rows into the deferred-peel account budget on each open. Terminal groups therefore held their per-group row slots and their share of the account byte budget forever; a disbanded group also showed pending deferred work in conformance snapshots beside zeroed unresolved inputs. Retire the rows silently at every marker site: inside the disband settle transaction beside delete_deferred_peel_generation (its idempotent re-entry never re-runs the body, so a purge outside it could be skipped forever after a crash), at realize_self_eviction, at the commit-apply self-removed arm (realize_self_eviction early-returns once removed is written, so that seam owns its own purge), at the convergence-reorg marker, and once more in the terminal gate as crash-window recovery. The durable flip enumerates payload-free metadata and runs inside the transaction; the in-memory budget release runs after commit, safe because the counters are derived state rebuilt from durable rows on open. Rows go to Failed like every other retired deferred row, and nothing on this path emits TransportObjectResourceRefused. Consolidate the engine's hand-rolled terminal predicates onto Group::is_terminal where the predicate is genuinely either kind of terminal; the send gates stay on removed because the tombstone gate above each already refuses a disbanded copy and their error names removal. Tests drive every behavior through ingest and advance_convergence. The regression guard for a row terminalized during a sweep is re-pointed at a live-group shape so it enumerates rows again. * fix(engine): flip a terminal group's deferred rows in one transaction Outside a transaction each update_message_state autocommitted, so a storage error mid-loop left earlier rows durably Failed with their budget slot still charged and no transition audit, and a retry that enumerates PeelDeferred only never saw them. Run the durable half of the retire in one transaction; a failed retire now leaves every row PeelDeferred for the next pass. Nesting is safe: the SQLite backend reuses a same-thread outer transaction, so the disband settle is unaffected. Pinned with a FaultStorage test that fails the second Failed-state write during a removal and asserts both rows stay deferred, then retires both on the next advance.
1 parent fdd398a commit 615d0c1

9 files changed

Lines changed: 994 additions & 37 deletions

File tree

crates/cgka-engine/src/disband.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use cgka_traits::app_components::{
1515
use cgka_traits::engine::SendResult;
1616
use cgka_traits::engine_state::{EpochState, StagedCommitHandle};
1717
use cgka_traits::error::EngineError;
18+
use cgka_traits::message::DeferredMessageMetadata;
1819
use cgka_traits::storage::{
1920
DisbandCandidate, DisbandFailureReason, DisbandRequest, DisbandRequestStatus, StorageError,
2021
StorageProvider,
@@ -545,8 +546,8 @@ impl<S: StorageProvider> Engine<S> {
545546
announced: false,
546547
};
547548

548-
self.storage
549-
.with_transaction(|storage| -> Result<(), EngineError> {
549+
let retired_deferred_rows = self.storage.with_transaction(
550+
|storage| -> Result<Vec<DeferredMessageMetadata>, EngineError> {
550551
storage.put_disband_tombstone(group_id, &tombstone)?;
551552
let mut group = storage.get_group(group_id)?;
552553
group.epoch = epoch;
@@ -573,6 +574,16 @@ impl<S: StorageProvider> Engine<S> {
573574
}
574575
storage.delete_convergence_pass(group_id)?;
575576
storage.delete_deferred_peel_generation(group_id)?;
577+
// The generation barrier is gone and the rows it tracked
578+
// must go with it (see
579+
// `Engine::retire_deferred_peel_rows_for_terminal_group`).
580+
// Durably retired on this transaction, not after it: the early
581+
// return above makes a re-entry after a crash skip this body
582+
// forever.
583+
let retired_deferred_rows =
584+
crate::message_processor::fail_deferred_peel_rows_in_terminal_group(
585+
storage, group_id,
586+
)?;
576587
for snapshot in storage.list_group_snapshots(group_id)? {
577588
storage.release_group_snapshot(group_id, &snapshot)?;
578589
}
@@ -597,8 +608,10 @@ impl<S: StorageProvider> Engine<S> {
597608
mls_group.delete(tx_provider.storage()).map_err(|error| {
598609
EngineError::Backend(format!("delete MLS group: {error:?}"))
599610
})?;
600-
Ok(())
601-
})?;
611+
Ok(retired_deferred_rows)
612+
},
613+
)?;
614+
self.release_retired_deferred_peel_rows(&retired_deferred_rows);
602615

603616
self.transport_group_id_index
604617
.retain(|_, mapped_group| mapped_group != group_id);

crates/cgka-engine/src/distributed_convergence.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1816,6 +1816,11 @@ impl<S: StorageProvider> Engine<S> {
18161816
// removed-copy send gate.
18171817
self.discard_queued_outbound_intents_for_removed_group(group_id)
18181818
.map_err(|e| OpenMlsProjectionError::Storage(format!("{e:?}")))?;
1819+
// Same for retained inbound rows: see
1820+
// `retire_deferred_peel_rows_for_terminal_group` for why no
1821+
// later sweep can reach them.
1822+
self.retire_deferred_peel_rows_for_terminal_group(group_id)
1823+
.map_err(|e| OpenMlsProjectionError::Storage(format!("{e:?}")))?;
18191824
}
18201825
self.push_group_state_change(
18211826
group_id,

crates/cgka-engine/src/message_processor/ingest.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,13 @@ impl<S: StorageProvider> Engine<S> {
15991599
// intents so later drains do not re-fail them forever
16001600
// against the removed-copy send gate.
16011601
self.discard_queued_outbound_intents_for_removed_group(&group_id)?;
1602+
// And retire the deferred-peel backlog (see
1603+
// `retire_deferred_peel_rows_for_terminal_group`). This
1604+
// seam owns that here rather than deferring to
1605+
// `realize_self_eviction`: the transaction above already
1606+
// wrote `removed`, so a later realization early-returns
1607+
// without ever reaching it.
1608+
self.retire_deferred_peel_rows_for_terminal_group(&group_id)?;
16021609
} else if after_ids.contains(self.identity.self_id()) {
16031610
if self.load_leave_request_state(&group_id)?.is_some() {
16041611
// A SelfRemove proposal is valid only in its
@@ -2246,6 +2253,10 @@ impl<S: StorageProvider> Engine<S> {
22462253
// leaving them to re-fail through the removed-copy send gate on every
22472254
// later drain.
22482255
self.discard_queued_outbound_intents_for_removed_group(group_id)?;
2256+
// Same for retained inbound work: see
2257+
// `retire_deferred_peel_rows_for_terminal_group` for why no later
2258+
// sweep can reach these rows.
2259+
self.retire_deferred_peel_rows_for_terminal_group(group_id)?;
22492260
// Deliberately LAST, after the marker write — not before it like the
22502261
// convergence path (which has no attribution read). The notification
22512262
// is already enqueued above, so a failure here cannot lose it; it only

crates/cgka-engine/src/message_processor/mod.rs

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod store;
1414

1515
pub(crate) use ingest::avatar_component_snapshot;
1616
pub(crate) use send::merge_capabilities;
17+
pub(crate) use store::fail_deferred_peel_rows_in_terminal_group;
1718
#[cfg(feature = "test-conformance-snapshot")]
1819
pub(crate) use store::normalized_deferred_peel_lifecycle;
1920
pub(crate) use store::transition_staged_invite_welcomes;
@@ -752,7 +753,8 @@ impl<S: StorageProvider> Engine<S> {
752753
// Terminal gate before queueing: a local copy marked removed (realized
753754
// self-eviction) must never accept or queue outbound work. Checked
754755
// again in `do_send_ready` so queued-intent drains for a copy removed
755-
// after queueing hit the same deterministic error.
756+
// after queueing hit the same deterministic error. Disband reaches the
757+
// tombstone gate above first, so the message below stays accurate.
756758
if group.as_ref().is_some_and(|group| group.removed) {
757759
return Err(EngineError::InvalidTransition(
758760
cgka_traits::engine_state::InvalidTransition {
@@ -965,14 +967,19 @@ impl<S: StorageProvider> Engine<S> {
965967
if self.sync_unrecoverable_halt_from_record(group_id, group.as_ref()) {
966968
return Ok(false);
967969
}
968-
// Terminal: a removed copy must never publish, and the removed-copy
970+
// Terminal: a terminal copy must never publish, and the terminal-copy
969971
// gate in `do_send_ready` would turn every queued record into a
970972
// permanent drain error that the app retries forever. Discard the
971-
// queue and report nothing to drain. This is the defense-in-depth
972-
// side; the marker sites (realization, commit-apply seam, convergence
973-
// reorg) also purge at the moment the copy becomes removed.
974-
if group.is_some_and(|group| group.removed) {
973+
// queue, retire the deferred-peel backlog, and report nothing to
974+
// drain. This gate is also what keeps the sweep away from a *removed*
975+
// copy, which stays `Stable` — so it owes the retirement itself, and
976+
// it is the only recovery if a crash lands between a marker site's
977+
// record write and its own purge. The marker sites (realization,
978+
// commit-apply seam, convergence reorg, disband settle) purge both at
979+
// the moment the copy becomes terminal.
980+
if group.is_some_and(|group| group.is_terminal()) {
975981
self.discard_queued_outbound_intents_for_removed_group(group_id)?;
982+
self.retire_deferred_peel_rows_for_terminal_group(group_id)?;
976983
return Ok(false);
977984
}
978985
if let Some(state) = self.epoch_manager.state(group_id)
@@ -1655,7 +1662,7 @@ impl<S: StorageProvider> Engine<S> {
16551662
if delay.is_some()
16561663
&& self
16571664
.stored_group_record(group_id)?
1658-
.is_none_or(|group| group.removed)
1665+
.is_none_or(|group| group.is_terminal())
16591666
{
16601667
// Input-only convergence can realize our eviction while fanout
16611668
// blocks the outbound drain. Removed copies exit that drain before
@@ -2921,10 +2928,21 @@ impl<S: StorageProvider> Engine<S> {
29212928
/// cap-rejection audit re-arms so a fresh cap-full episode is recorded
29222929
/// once more.
29232930
pub(crate) fn note_peel_deferred_row_retired(&mut self, record: &MessageRecord) {
2931+
self.note_peel_deferred_row_retired_by_id(&record.group_id, &record.id);
2932+
}
2933+
2934+
/// [`Self::note_peel_deferred_row_retired`] keyed by the only two fields
2935+
/// it reads, for callers holding metadata rather than a whole record (the
2936+
/// payload bytes come from `deferred_payload_bytes_by_id`, not the row).
2937+
pub(crate) fn note_peel_deferred_row_retired_by_id(
2938+
&mut self,
2939+
group_id: &GroupId,
2940+
id: &MessageId,
2941+
) {
29242942
let account_was_full = self.deferred_peel_account.counted
29252943
&& self.deferred_peel_account.bytes >= self.deferred_peel_account_byte_limit;
2926-
let local_reopened = if let Some(state) = self.deferred_peel.get_mut(&record.group_id) {
2927-
let Some(payload_bytes) = state.deferred_payload_bytes_by_id.remove(&record.id) else {
2944+
let local_reopened = if let Some(state) = self.deferred_peel.get_mut(group_id) {
2945+
let Some(payload_bytes) = state.deferred_payload_bytes_by_id.remove(id) else {
29282946
return;
29292947
};
29302948
state.deferred_rows = state.deferred_rows.saturating_sub(1);
@@ -2950,7 +2968,7 @@ impl<S: StorageProvider> Engine<S> {
29502968
for state in self.deferred_peel.values_mut() {
29512969
state.cap_rejection_audited = false;
29522970
}
2953-
} else if local_reopened && let Some(state) = self.deferred_peel.get_mut(&record.group_id) {
2971+
} else if local_reopened && let Some(state) = self.deferred_peel.get_mut(group_id) {
29542972
state.cap_rejection_audited = false;
29552973
}
29562974
}

crates/cgka-engine/src/message_processor/send.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ impl<S: StorageProvider> Engine<S> {
5454
// fail with an opaque `UseAfterEviction` backend error anyway. Gate
5555
// here (not just `do_send`) so queued-intent drains hit the same
5656
// deterministic terminal error. Every intent kind is blocked,
57-
// including `Leave` — there is nothing left to leave.
57+
// including `Leave` — there is nothing left to leave. Disband reaches
58+
// the tombstone gate above first, so the message below stays
59+
// accurate.
5860
let group = self.stored_group_record(&group_id)?;
5961
if group.as_ref().is_some_and(|group| group.removed) {
6062
return Err(EngineError::InvalidTransition(

crates/cgka-engine/src/message_processor/store.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,39 @@ fn fresh_deferred_peel_lifecycle(
3232
}
3333
}
3434

35+
/// Durable half of `Engine::retire_deferred_peel_rows_for_terminal_group`:
36+
/// flip every `PeelDeferred` row of a terminal group to `Failed` and hand back
37+
/// what was retired, for the caller's in-memory reconciliation. Takes `&S` so
38+
/// it runs inside the caller's storage transaction.
39+
///
40+
/// All rows flip or none do, and that is a contract on the caller: run this
41+
/// inside a transaction — the disband settle's own, or the one
42+
/// `Engine::retire_deferred_peel_rows_for_terminal_group` opens. Without one,
43+
/// each write autocommits, so a mid-loop failure would leave earlier rows
44+
/// durably `Failed` while the error skips the in-memory release: those rows
45+
/// would keep their capacity slot with no transition audit, and a retry
46+
/// enumerates only `PeelDeferred` rows so it would never revisit them. Inside
47+
/// a transaction a failed retire instead leaves every row `PeelDeferred` for
48+
/// the next pass, and nothing is ever charged without its audit row.
49+
///
50+
/// Enumerates metadata, never payloads: this runs inside the disband write
51+
/// transaction, where loading up to `MAX_PEEL_DEFERRED_BYTES_PER_GROUP` of
52+
/// ciphertext nobody reads would be copied and held across the commit.
53+
#[must_use = "every returned row still owes its in-memory capacity slot; pass \
54+
them to Engine::release_retired_deferred_peel_rows once the \
55+
durable flip is committed, or the group's deferred-peel budget \
56+
stays charged for the rest of this engine incarnation"]
57+
pub(crate) fn fail_deferred_peel_rows_in_terminal_group<S: StorageProvider>(
58+
storage: &S,
59+
group_id: &GroupId,
60+
) -> Result<Vec<DeferredMessageMetadata>, EngineError> {
61+
let retired = storage.list_deferred_message_metadata(group_id)?;
62+
for row in &retired {
63+
storage.update_message_state(&row.id, MessageState::Failed)?;
64+
}
65+
Ok(retired)
66+
}
67+
3568
/// Promote or retire the non-deliverable Welcome artifacts produced beside one
3669
/// staged invite commit.
3770
///
@@ -723,6 +756,80 @@ impl<S: StorageProvider> Engine<S> {
723756
Ok(())
724757
}
725758

759+
/// Retire this group's whole `PeelDeferred` backlog because the local copy
760+
/// has become terminal — removed or disbanded.
761+
///
762+
/// Why nothing else will: the only production driver that reaches the
763+
/// deferred-peel sweep is `advance_convergence_inputs`, and its single
764+
/// door is `prepare_convergence_input_advance`, whose terminal gate
765+
/// refuses a terminal group before any sweep runs. A *disbanded* copy is
766+
/// refused twice over (its `EpochState::Disbanded` also fails that
767+
/// function's `Stable` check), but a *removed* copy stays `Stable` — the
768+
/// terminal gate is the whole reason its rows never come back. (The `pub`
769+
/// `retry_deferred_peels` would sweep a removed group happily; no
770+
/// production path calls it.) So without this the rows sit forever holding
771+
/// their share of the account byte budget — reconstructed from durable
772+
/// rows on every open — and their per-group row slots.
773+
///
774+
/// Silent by construction: the rows leave the retry lifecycle the way
775+
/// [`Self::mark_raw_transport_message_failed_if_awaiting_retry`] retires
776+
/// one, with no `TransportObjectResourceRefused` — nothing was refused,
777+
/// the group they belonged to is gone.
778+
///
779+
/// The tradeoff, taken deliberately: `removed` is reversible (branch
780+
/// selection can supersede the removal that set it — see
781+
/// `cgka_traits::group::Group::removed` and the heal in
782+
/// `distributed_convergence::emit_convergence_events`), and a `Failed` row
783+
/// blocks same-id redelivery, so a heal cannot get these rows back. That
784+
/// is the same bet
785+
/// [`Self::discard_queued_outbound_intents_for_removed_group`] already
786+
/// makes beside every call site here, and the window is narrow because
787+
/// the terminal gate refuses further advances until the heal lands.
788+
pub(crate) fn retire_deferred_peel_rows_for_terminal_group(
789+
&mut self,
790+
group_id: &GroupId,
791+
) -> Result<(), EngineError> {
792+
// One durable unit: see the free function's contract. Nesting is
793+
// safe — the SQLite backend reuses a same-thread outer transaction
794+
// rather than beginning a second one — so a caller that already holds
795+
// one loses nothing by coming through here.
796+
let retired = self.storage.with_transaction(|storage| {
797+
fail_deferred_peel_rows_in_terminal_group(storage, group_id)
798+
})?;
799+
self.release_retired_deferred_peel_rows(&retired);
800+
Ok(())
801+
}
802+
803+
/// In-memory half of [`Self::retire_deferred_peel_rows_for_terminal_group`]:
804+
/// return each retired row's capacity slot and record the transition.
805+
///
806+
/// Split from the durable half so a caller already inside a storage
807+
/// transaction — `disband::settle_disband_after_convergence`, whose
808+
/// idempotent re-entry never re-runs the settle body — can keep the row
809+
/// flip inside that transaction and reconcile the engine's own counters
810+
/// after it commits. Safe in that order: the counters are derived state,
811+
/// rebuilt from the durable rows by
812+
/// `ensure_peel_deferred_usage_initialized` on the next open, so a crash
813+
/// between the two loses nothing.
814+
pub(crate) fn release_retired_deferred_peel_rows(
815+
&mut self,
816+
retired: &[DeferredMessageMetadata],
817+
) {
818+
for row in retired {
819+
self.audit_group(
820+
&row.group_id,
821+
crate::audit_helpers::message_state_transition_event(
822+
hex::encode(row.id.as_slice()),
823+
Some(MessageState::PeelDeferred),
824+
MessageState::Failed,
825+
Some(row.epoch),
826+
"terminal_group",
827+
),
828+
);
829+
self.note_peel_deferred_row_retired_by_id(&row.group_id, &row.id);
830+
}
831+
}
832+
726833
pub(crate) fn mark_raw_transport_message_failed_if_awaiting_retry(
727834
&mut self,
728835
raw_msg_id: &MessageId,

crates/cgka-engine/src/own_commit_intent.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ impl<S: StorageProvider> Engine<S> {
171171
None,
172172
)));
173173
};
174-
if group.removed || group.disbanded.is_some() {
174+
if group.is_terminal() {
175175
return Ok(Some((
176176
report(
177177
SupersededIntentOutcome::NotMember,
@@ -468,10 +468,7 @@ impl<S: StorageProvider> Engine<S> {
468468
outcome,
469469
reason,
470470
};
471-
if group
472-
.as_ref()
473-
.is_none_or(|group| group.removed || group.disbanded.is_some())
474-
{
471+
if group.as_ref().is_none_or(|group| group.is_terminal()) {
475472
self.storage.delete_own_commit_intent(commit_id)?;
476473
return Ok(Some(report(
477474
SupersededIntentOutcome::NotMember,

0 commit comments

Comments
 (0)