You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When ControlLoop faults it sets IsFaulted = true, logs a Critical message, and returns. Nothing durable records the cause. After a pod restart the reason is gone: an operator must mine structured logs by timestamp, correlate pod names, and hope log retention reaches back far enough.
ControlLoop is a public sealed class (ControlLoop.cs:17) with public bool IsFaulted { get; private set; } (ControlLoop.cs:39) and an internal constructor (ControlLoop.cs:42). In production DI the instance is wrapped in the internal ControlLoopGroup and registered only as IHostedService (ControlLoopRegistration.cs:80), so external callers cannot conveniently read IsFaulted without a cast. No production code reads it at all today — the only readers are tests (ControlLoopTests.cs:323/330, DiscoveredIssuesTests.cs:291/356/414, ControlLoopMiddlewareTests.cs).
The pipelined path compounds the problem. The worker catch block (ControlLoop.cs:471-481) has evt.GlobalPosition, evt.EventType.Id, and evt.TenantId in scope when it calls reportFailure(ex) (:479), but ReportFailure accepts only Action<Exception> (ControlLoop.cs:428), discarding all event context before the Critical log fires in the finally block (ControlLoop.cs:415-423).
In the sequential path the outer catch that sets IsFaulted = true (ControlLoop.cs:275) sits outside the foreach over relevantEvents, so the event position is no longer in scope at fault time.
The result: after any restart, operators cannot distinguish an infrastructure failure from an event-processing bug, cannot identify which event caused the stop, and cannot verify that the processor recovered cleanly.
Scope clarification
Dead-letter entries (DeadLetterEntry.cs) already capture GlobalPosition, EventType, TenantId, ErrorMessage, and StackTrace for every event that flows through the middleware chain and exhausts retries. That covers the per-event story. What is missing is the processor-level stopped-with-reason record: one durable row saying "this processor is halted, stopped at time T because of exception E, while processing the event at position P (if known)". The two records are complementary, not redundant.
Evidence
File
Line
What
src/Alberto.Dcb/Subscriptions/ControlLoop.cs
39
IsFaulted property
src/Alberto.Dcb/Subscriptions/ControlLoop.cs
275
IsFaulted = true in sequential outer catch — event position out of scope at this level
src/Alberto.Dcb/Subscriptions/ControlLoop.cs
417
IsFaulted = true from pipelineFailure in the pipelined finally block
src/Alberto.Dcb/Subscriptions/ControlLoop.cs
428
void ReportFailure(Exception failure) — event context is not carried through
src/Alberto.Dcb/Subscriptions/ControlLoop.cs
430
Interlocked.CompareExchange — only the first failure wins
src/Alberto.Dcb/Subscriptions/ControlLoop.cs
479
reportFailure(ex) in the worker catch, with full event context in scope
SaveAsync — columns from Migrations/001_InitialSchema.sql (processor_id, last_position, updated_at) and Migrations/021_CheckpointFenceTokens.sql (fence_token); no fault columns
RewindAsync writes directly to _inner and clears the cache — the bypass pattern for durable immediate writes
Proposal
Context available at fault time
Sequential path (RunAsync outer catch, :275):ProcessorId and the Exception are available; the event position is not in scope (the evt variable lives inside the foreach, inside the inner try). Capturing it needs long? _lastDispatchedPosition, string? _lastDispatchedEventType, and string? _lastDispatchedTenantId tracking fields set inside DispatchAsync before dispatching, then read in the catch.
Pipelined path (worker catch, :471-481): the worker has full event context in scope. Carrying it through means changing ReportFailure from Action<Exception> to Action<Exception, EventFaultContext?>:
pipelineFailure becomes a pair (Exception, EventFaultContext?) — null context for infrastructure failures from the producer loop, populated for a worker failure.
Storage: new nullable columns on alberto_processor_checkpoints
-- Migrations/035_ProcessorFaultRecord.sql (multi-tenant)-- Migrations/SingleTenant/035_ProcessorFaultRecord.sql (single-tenant)ALTERTABLE $schema_prefix$alberto_processor_checkpoints
ADD COLUMN IF NOT EXISTS faulted_at TIMESTAMPTZ DEFAULT NULL,
ADD COLUMN IF NOT EXISTS fault_message TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS fault_stack_trace TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS fault_position BIGINT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS fault_event_type TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS fault_tenant_id TEXT DEFAULT NULL;
All six are NULL when the processor is healthy or has never faulted. Non-NULL faulted_at is the canonical stopped signal.
A write path upserts only the fault columns, leaving last_position, fence_token, and updated_at untouched. The ON CONFLICT DO UPDATE arm is correct for the normal case (a checkpoint row already exists). The INSERT arm for the no-existing-row case must not use last_position = 0: if a processor faults on its very first run before saving any checkpoint, inserting a row at position 0 makes a restart re-stream from 0. Use INSERT ... ON CONFLICT DO NOTHING plus a separate UPDATE ... WHERE processor_id = @processor_id in one function, or guard the INSERT so it fires only when a row already exists.
A separate clear path NULLs all six columns on recovery.
Internal to Alberto.Dcb, reachable from Alberto.Dcb.InMemory and Alberto.Dcb.Postgres through the existing InternalsVisibleTo grants — no new grants needed.
PostgresCheckpointStore implements it; writes go straight to the database, bypassing CachingCheckpointStore's flush buffer (same pattern as RewindAsync), since fault records must be immediately durable.
InMemoryCheckpointStore implements it with a Dictionary<string, ProcessorFaultRecord?> under the existing lock.
CachingCheckpointStore passes fault calls straight through to _inner (_inner is IProcessorFaultStore), bypassing the dirty buffer, and clears the fault on ResetAsync (:145) and RewindAsync (:156) via the same passthrough.
IsFaulted=true;if(_faultStoreis not null)await_faultStore.RecordFaultAsync(ProcessorId,newProcessorFaultRecord(ex.Message,ex.StackTrace,_lastDispatchedPosition,_lastDispatchedEventType,_lastDispatchedTenantId),CancellationToken.None);
Pipelined finally block (:415-423), after the Critical log:
if(pipelineFailureis not null&&_faultStoreis not null)await_faultStore.RecordFaultAsync(ProcessorId,newProcessorFaultRecord(pipelineFailure.Value.Ex.Message,pipelineFailure.Value.Ex.StackTrace,pipelineFailure.Value.Ctx?.Position,pipelineFailure.Value.Ctx?.EventType,pipelineFailure.Value.Ctx?.TenantId),CancellationToken.None);
The tracking fields are written only by RunAsync and read only in its own outer catch, so there is no cross-path race — the pipelined path uses the pipelineFailure pair and never touches them.
Ordering note (post-#71): the pipelined finally block now also runs the bounded-drain logic added in #71 (ControlLoop.cs:400-413) and may hand an abandoned worker set to DisposeWhenDrainedAsync. The fault write must sit after SaveWatermarkCheckpointAsync and must not be gated on the drain succeeding — a processor that both faulted and failed to drain is exactly the case an operator most needs a durable record for.
Recovery / clear: the loop exits immediately after recording the fault — there is no next iteration in the same instance. ClearFaultAsync therefore belongs in the new ControlLoop created on the next host start, fired after the first successful SaveAsync proves the processor can advance. ResetAsync and RewindAsync also clear via the store passthrough.
Admin surface
CheckpointInfo lives at src/Alberto.Dcb.Admin/AdminTypes.cs:17:
PostgresAdminDataAccess moved to its own project in #70 — it is now src/Alberto.Dcb.Postgres.Admin/PostgresAdminDataAccess.cs, and it constructs CheckpointInfo positionally at :103 (GetCheckpointsAsync) and :129 (GetSingleCheckpointAsync). Both sites need updating.
The CLI consumer is alberto status, not alberto processors list — tools/Alberto.Cli/Commands/StatusCommand.cs:59-64 projects ProcessorId, LastPosition, and UpdatedAt. That is where FaultedAt / FaultMessage should surface when non-null.
Note that both Alberto.Dcb.Admin and Alberto.Dcb.Postgres.Admin were parked (IsPackable=false) in #70, and every alberto ops mutation now routes through IAdminOperator. This work extends a parked surface — worth confirming that is intended before starting.
Both sets currently top out at 034_OutboxRetentionIndex.sql, so 035 is the next number for each. (The SingleTenant set is missing 012 — it jumps 011 → 013 — but that does not affect the next allocation.) The Migrations/Catalog/ set holds only alberto_tenant_shards and needs no change.
Non-breaking: all six columns are nullable with DEFAULT NULL, existing rows unaffected, and the GREATEST-guarded SaveAsync upsert uses an explicit column list that does not touch them. A zero-downtime rolling deploy is safe.
Public API impact
The public-API gate is armed.#70 removed the root .editorconfig suppressions, publicapi-silence.globalconfig, the project-local src/Alberto.Dcb/.editorconfig, and the <NoWarn> group; RS00xx is now at error, with a captured baseline of 2870 entries across 11 packages.
Alberto.Dcb.Admin has both PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt (327 entries). CheckpointInfo and its positional constructor are baselined at PublicAPI.Unshipped.txt:149-156:
Alberto.Dcb.Admin.CheckpointInfo.CheckpointInfo(string! ProcessorId, long LastPosition, System.DateTimeOffset? UpdatedAt) -> void
Adding parameters to that record therefore breaks the build (RS0016 for the new members, RS0017 for the now-stale constructor entry) until the file is updated in the same PR. This is a build-time requirement now, not something deferred to a v1 gate.
IProcessorFaultStore and ProcessorFaultRecord are internal — no entries needed for Alberto.Dcb, Alberto.Dcb.Postgres, or Alberto.Dcb.InMemory. Alberto.Dcb.Postgres.Admin has no PublicAPI files of its own.
ControlLoopFaultsAndRecordsReason_RunAsync — force an exception past the middleware on a loop backed by an InMemoryCheckpointStore implementing IProcessorFaultStore; assert the fault message is recorded. No Testcontainers needed.
ControlLoopFaultsAndRecordsEventContext_RunPipelinedAsync — MaxConcurrency=2; inject a worker exception; assert fault_position and fault_event_type match the offending event.
ControlLoopClearsFaultOnRecovery — record a fault, create a new loop, let one successful checkpoint save complete, assert all fault fields are NULL.
ControlLoopFault_InfrastructureError_NoEventContext — make StreamAllAsync throw before any dispatch; assert fault_position is NULL and the message is not.
ControlLoopFault_RecordedEvenWhenDrainIsAbandoned — pipelined loop with both a faulting worker and a worker that ignores cancellation; assert the fault row is written despite the abandoned drain (guards the Bound the shutdown drain so a stuck handler cannot stall the host #71 interaction).
CachingCheckpointStore_FaultWriteBypassesFlushBuffer — assert the inner store receives the write immediately.
AdminReader_GetCheckpoints_IncludesFaultFields — after a fault, both read methods return non-null FaultedAt / FaultMessage. (Testcontainers.)
Migration035_IsIdempotent — run twice on a live database; no error, columns exist with the right nullability. (Testcontainers.)
Risks
CheckpointInfo is a positional record with a baselined constructor entry. Adding parameters is source-breaking for positional callers and an RS0017 build error until PublicAPI.Unshipped.txt is updated. Decide up front: stay positional (update the two call sites plus the baseline) or add a separate GetProcessorFaultAsync to IAdminReader and leave CheckpointInfo alone.
The fault write uses CancellationToken.None (the loop is already exiting). If the database is unreachable — plausibly the very cause of the fault — RecordFaultAsync hangs until the Npgsql connection timeout. Give the fault write its own short command timeout. Note this now compounds with the drain budget from Bound the shutdown drain so a stuck handler cannot stall the host #71: a hung fault write inside the finally block eats into host shutdown.
The pipelined path records only the first failure's event context (Interlocked.CompareExchange, ControlLoop.cs:430). Concurrent worker failures lose the second context. Dead letters already cover the multi-event case.
Clear the fault record on the first successful SaveAsync of the new run, or only on an explicit operator reset/rewind? Automatic clearing is ergonomic but silently wipes evidence of a transient blip that self-healed.
Truncate fault_stack_trace to a fixed length (e.g. 4 000 chars, matching the dead-letter schema) rather than storing it in full? Deep stacks can run to hundreds of KB per row.
Extend CheckpointInfo, or add GetProcessorFaultAsync to IAdminReader to keep the checkpoint contract and its PublicAPI baseline stable?
Problem
When
ControlLoopfaults it setsIsFaulted = true, logs a Critical message, and returns. Nothing durable records the cause. After a pod restart the reason is gone: an operator must mine structured logs by timestamp, correlate pod names, and hope log retention reaches back far enough.ControlLoopis apublic sealed class(ControlLoop.cs:17) withpublic bool IsFaulted { get; private set; }(ControlLoop.cs:39) and aninternalconstructor (ControlLoop.cs:42). In production DI the instance is wrapped in the internalControlLoopGroupand registered only asIHostedService(ControlLoopRegistration.cs:80), so external callers cannot conveniently readIsFaultedwithout a cast. No production code reads it at all today — the only readers are tests (ControlLoopTests.cs:323/330,DiscoveredIssuesTests.cs:291/356/414,ControlLoopMiddlewareTests.cs).The pipelined path compounds the problem. The worker catch block (
ControlLoop.cs:471-481) hasevt.GlobalPosition,evt.EventType.Id, andevt.TenantIdin scope when it callsreportFailure(ex)(:479), butReportFailureaccepts onlyAction<Exception>(ControlLoop.cs:428), discarding all event context before the Critical log fires in the finally block (ControlLoop.cs:415-423).In the sequential path the outer catch that sets
IsFaulted = true(ControlLoop.cs:275) sits outside theforeachoverrelevantEvents, so the event position is no longer in scope at fault time.The result: after any restart, operators cannot distinguish an infrastructure failure from an event-processing bug, cannot identify which event caused the stop, and cannot verify that the processor recovered cleanly.
Scope clarification
Dead-letter entries (
DeadLetterEntry.cs) already captureGlobalPosition,EventType,TenantId,ErrorMessage, andStackTracefor every event that flows through the middleware chain and exhausts retries. That covers the per-event story. What is missing is the processor-level stopped-with-reason record: one durable row saying "this processor is halted, stopped at time T because of exception E, while processing the event at position P (if known)". The two records are complementary, not redundant.Evidence
src/Alberto.Dcb/Subscriptions/ControlLoop.csIsFaultedpropertysrc/Alberto.Dcb/Subscriptions/ControlLoop.csIsFaulted = truein sequential outer catch — event position out of scope at this levelsrc/Alberto.Dcb/Subscriptions/ControlLoop.csIsFaulted = truefrompipelineFailurein the pipelined finally blocksrc/Alberto.Dcb/Subscriptions/ControlLoop.csvoid ReportFailure(Exception failure)— event context is not carried throughsrc/Alberto.Dcb/Subscriptions/ControlLoop.csInterlocked.CompareExchange— only the first failure winssrc/Alberto.Dcb/Subscriptions/ControlLoop.csreportFailure(ex)in the worker catch, with full event context in scopesrc/Alberto.Dcb/Subscriptions/DeadLetterEntry.cssrc/Alberto.Dcb.Postgres/PostgresCheckpointStore.csSaveAsync— columns fromMigrations/001_InitialSchema.sql(processor_id, last_position, updated_at) andMigrations/021_CheckpointFenceTokens.sql(fence_token); no fault columnssrc/Alberto.Dcb/Subscriptions/CachingCheckpointStore.csRewindAsyncwrites directly to_innerand clears the cache — the bypass pattern for durable immediate writesProposal
Context available at fault time
Sequential path (
RunAsyncouter catch,:275):ProcessorIdand theExceptionare available; the event position is not in scope (theevtvariable lives inside theforeach, inside the inner try). Capturing it needslong? _lastDispatchedPosition,string? _lastDispatchedEventType, andstring? _lastDispatchedTenantIdtracking fields set insideDispatchAsyncbefore dispatching, then read in the catch.Pipelined path (worker catch,
:471-481): the worker has full event context in scope. Carrying it through means changingReportFailurefromAction<Exception>toAction<Exception, EventFaultContext?>:pipelineFailurebecomes a pair(Exception, EventFaultContext?)—nullcontext for infrastructure failures from the producer loop, populated for a worker failure.Storage: new nullable columns on
alberto_processor_checkpointsAll six are NULL when the processor is healthy or has never faulted. Non-NULL
faulted_atis the canonical stopped signal.A write path upserts only the fault columns, leaving
last_position,fence_token, andupdated_atuntouched. TheON CONFLICT DO UPDATEarm is correct for the normal case (a checkpoint row already exists). The INSERT arm for the no-existing-row case must not uselast_position = 0: if a processor faults on its very first run before saving any checkpoint, inserting a row at position 0 makes a restart re-stream from 0. UseINSERT ... ON CONFLICT DO NOTHINGplus a separateUPDATE ... WHERE processor_id = @processor_idin one function, or guard the INSERT so it fires only when a row already exists.A separate clear path NULLs all six columns on recovery.
IProcessorFaultStore
Internal to
Alberto.Dcb, reachable fromAlberto.Dcb.InMemoryandAlberto.Dcb.Postgresthrough the existingInternalsVisibleTogrants — no new grants needed.PostgresCheckpointStoreimplements it; writes go straight to the database, bypassingCachingCheckpointStore's flush buffer (same pattern asRewindAsync), since fault records must be immediately durable.InMemoryCheckpointStoreimplements it with aDictionary<string, ProcessorFaultRecord?>under the existing lock.CachingCheckpointStorepasses fault calls straight through to_inner(_inner is IProcessorFaultStore), bypassing the dirty buffer, and clears the fault onResetAsync(:145) andRewindAsync(:156) via the same passthrough.ControlLoop changes
Sequential outer catch (
:275):Pipelined finally block (
:415-423), after the Critical log:The tracking fields are written only by
RunAsyncand read only in its own outer catch, so there is no cross-path race — the pipelined path uses thepipelineFailurepair and never touches them.Ordering note (post-#71): the pipelined finally block now also runs the bounded-drain logic added in #71 (
ControlLoop.cs:400-413) and may hand an abandoned worker set toDisposeWhenDrainedAsync. The fault write must sit afterSaveWatermarkCheckpointAsyncand must not be gated on the drain succeeding — a processor that both faulted and failed to drain is exactly the case an operator most needs a durable record for.Recovery / clear: the loop exits immediately after recording the fault — there is no next iteration in the same instance.
ClearFaultAsynctherefore belongs in the newControlLoopcreated on the next host start, fired after the first successfulSaveAsyncproves the processor can advance.ResetAsyncandRewindAsyncalso clear via the store passthrough.Admin surface
CheckpointInfolives atsrc/Alberto.Dcb.Admin/AdminTypes.cs:17:PostgresAdminDataAccessmoved to its own project in #70 — it is nowsrc/Alberto.Dcb.Postgres.Admin/PostgresAdminDataAccess.cs, and it constructsCheckpointInfopositionally at:103(GetCheckpointsAsync) and:129(GetSingleCheckpointAsync). Both sites need updating.The CLI consumer is
alberto status, notalberto processors list—tools/Alberto.Cli/Commands/StatusCommand.cs:59-64projectsProcessorId,LastPosition, andUpdatedAt. That is whereFaultedAt/FaultMessageshould surface when non-null.Note that both
Alberto.Dcb.AdminandAlberto.Dcb.Postgres.Adminwere parked (IsPackable=false) in #70, and everyalberto opsmutation now routes throughIAdminOperator. This work extends a parked surface — worth confirming that is intended before starting.Migration impact
Two new files:
src/Alberto.Dcb.Postgres/Migrations/035_ProcessorFaultRecord.sql(multi-tenant)src/Alberto.Dcb.Postgres/Migrations/SingleTenant/035_ProcessorFaultRecord.sql(single-tenant)Both sets currently top out at
034_OutboxRetentionIndex.sql, so 035 is the next number for each. (TheSingleTenantset is missing012— it jumps011→013— but that does not affect the next allocation.) TheMigrations/Catalog/set holds onlyalberto_tenant_shardsand needs no change.Non-breaking: all six columns are nullable with
DEFAULT NULL, existing rows unaffected, and theGREATEST-guardedSaveAsyncupsert uses an explicit column list that does not touch them. A zero-downtime rolling deploy is safe.Public API impact
The public-API gate is armed. #70 removed the root
.editorconfigsuppressions,publicapi-silence.globalconfig, the project-localsrc/Alberto.Dcb/.editorconfig, and the<NoWarn>group; RS00xx is now at error, with a captured baseline of 2870 entries across 11 packages.Alberto.Dcb.Adminhas bothPublicAPI.Shipped.txtandPublicAPI.Unshipped.txt(327 entries).CheckpointInfoand its positional constructor are baselined atPublicAPI.Unshipped.txt:149-156:Adding parameters to that record therefore breaks the build (RS0016 for the new members, RS0017 for the now-stale constructor entry) until the file is updated in the same PR. This is a build-time requirement now, not something deferred to a v1 gate.
IProcessorFaultStoreandProcessorFaultRecordare internal — no entries needed forAlberto.Dcb,Alberto.Dcb.Postgres, orAlberto.Dcb.InMemory.Alberto.Dcb.Postgres.Adminhas no PublicAPI files of its own.Affected files
src/Alberto.Dcb/Subscriptions/ControlLoop.cssrc/Alberto.Dcb/Subscriptions/CachingCheckpointStore.cssrc/Alberto.Dcb.Postgres/PostgresCheckpointStore.cssrc/Alberto.Dcb.InMemory/InMemoryCheckpointStore.cssrc/Alberto.Dcb.Admin/AdminTypes.cssrc/Alberto.Dcb.Admin/IAdminReader.cssrc/Alberto.Dcb.Admin/PublicAPI.Unshipped.txtsrc/Alberto.Dcb.Postgres.Admin/PostgresAdminDataAccess.cstools/Alberto.Cli/Commands/StatusCommand.cssrc/Alberto.Dcb.Postgres/Migrations/035_ProcessorFaultRecord.sqlsrc/Alberto.Dcb.Postgres/Migrations/SingleTenant/035_ProcessorFaultRecord.sqlTest plan
ControlLoopFaultsAndRecordsReason_RunAsync— force an exception past the middleware on a loop backed by anInMemoryCheckpointStoreimplementingIProcessorFaultStore; assert the fault message is recorded. No Testcontainers needed.ControlLoopFaultsAndRecordsEventContext_RunPipelinedAsync—MaxConcurrency=2; inject a worker exception; assertfault_positionandfault_event_typematch the offending event.ControlLoopClearsFaultOnRecovery— record a fault, create a new loop, let one successful checkpoint save complete, assert all fault fields are NULL.ControlLoopFault_InfrastructureError_NoEventContext— makeStreamAllAsyncthrow before any dispatch; assertfault_positionis NULL and the message is not.ControlLoopFault_RecordedEvenWhenDrainIsAbandoned— pipelined loop with both a faulting worker and a worker that ignores cancellation; assert the fault row is written despite the abandoned drain (guards the Bound the shutdown drain so a stuck handler cannot stall the host #71 interaction).InMemoryCheckpointStore_RecordFault_RoundTrip— record, read back, clear, assert null.CachingCheckpointStore_FaultWriteBypassesFlushBuffer— assert the inner store receives the write immediately.AdminReader_GetCheckpoints_IncludesFaultFields— after a fault, both read methods return non-nullFaultedAt/FaultMessage. (Testcontainers.)Migration035_IsIdempotent— run twice on a live database; no error, columns exist with the right nullability. (Testcontainers.)Risks
CheckpointInfois a positional record with a baselined constructor entry. Adding parameters is source-breaking for positional callers and an RS0017 build error untilPublicAPI.Unshipped.txtis updated. Decide up front: stay positional (update the two call sites plus the baseline) or add a separateGetProcessorFaultAsynctoIAdminReaderand leaveCheckpointInfoalone.CancellationToken.None(the loop is already exiting). If the database is unreachable — plausibly the very cause of the fault —RecordFaultAsynchangs until the Npgsql connection timeout. Give the fault write its own short command timeout. Note this now compounds with the drain budget from Bound the shutdown drain so a stuck handler cannot stall the host #71: a hung fault write inside the finally block eats into host shutdown.Interlocked.CompareExchange,ControlLoop.cs:430). Concurrent worker failures lose the second context. Dead letters already cover the multi-event case.IsPackable=false) as of Park the admin surface, audit every CLI mutation, expire delivered outbox entries #70. Extending them is fine, but confirm the surface is meant to grow rather than stay frozen.Open questions
SaveAsyncof the new run, or only on an explicit operatorreset/rewind? Automatic clearing is ergonomic but silently wipes evidence of a transient blip that self-healed.fault_stack_traceto a fixed length (e.g. 4 000 chars, matching the dead-letter schema) rather than storing it in full? Deep stacks can run to hundreds of KB per row.CheckpointInfo, or addGetProcessorFaultAsynctoIAdminReaderto keep the checkpoint contract and its PublicAPI baseline stable?