Skip to content

fix: Lane 0 — close the write-path shard bypasses and immutability gaps - #399

Merged
matthyx merged 18 commits into
mainfrom
fix/route-consolidation-delete-through-shard
Sep 8, 2026
Merged

fix: Lane 0 — close the write-path shard bypasses and immutability gaps#399
matthyx merged 18 commits into
mainfrom
fix/route-consolidation-delete-through-shard

Conversation

@matthyx

@matthyx matthyx commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #397 (sharded single-writer commits), which fixed a catastrophic write-concurrency regression in kubescape/node-agent's component-tests but left a residual, smaller flakiness (15-17/30 jobs failing, vs. a 0-1/30 historical baseline) — documented as an open gap in docs/features/single-writer-priority-queue.md's "Known gaps" section.

This PR grew from a single fix into "Lane 0" — a standalone-shippable bundle of every write-path bypass, invariant gap, and panic-containment issue found during a deep, multi-round audit of the shard/single-writer system (.omc/plans/raw-write-bypass-elimination.md), independent of the larger Step 3 phase-split redesign that will follow in a separate PR. Every fix below has an acceptance test that fails against pre-fix code and passes after.

What's in Lane 0

L0-C — the original fix. ConsolidateTimeSeries's delete step took a raw pool connection outside the write-shard system entirely, so a genuine SQLite write-lock collision blocked the loser for up to the full busy-timeout (60s in production) instead of the microsecond in-process wait every shard-routed write gets. Fixed by routing it through singleWriter.runOnShard. A later audit found the routed callback still synchronously dispatched its watch event while holding the shard's lock/connection — a slow watch consumer could freeze the whole shard. Fixed by splitting delete into a shard-safe leaf (deleteLocked) and dispatching the watch event from the caller afterward, matching the convention Create/Update already use.

L0-A — five correctness fixes on the consolidation write path:

  • PRE-3 — a defer endFn(&err) fix so a panic mid-transaction rolls back cleanly instead of leaking a held write lock.
  • Finding T — a one-line spec fix so a failed profile save reports no processed keys (closes a data-loss window).
  • Finding U — a transiently-unreadable time-series row could fork or orphan a profile's data during merge; now excluded from the pass entirely via kept-based filtering.
  • Finding X + E3 — makes "a Completed/Full profile is never updated again" true by construction (a write-time guard on both the consolidation and REST-adjacent write paths), plus a crash-recovery heal (HealDivergence) for the one regression that guard would otherwise introduce: a crash between payload-rename and metadata-commit leaving a profile's on-disk state ahead of its row. The heal takes the same per-key lock every other writer does, so it cannot race a live commit; verified against the vendored SQLite driver's exact commit/rollback semantics.
  • PC-TS-4 — removes a redundant delete call that was silently dropping unmerged rows.

L0-B — panic containment in the shard commit loop (singlewriter.go), since every write in the system now routes through it: a caller-supplied job panicking used to either crash the process or wedge its shard forever. Three layers — callGuarded (recovers a panicking job into an error for its caller), putChecked (ensures the shard's pool connection is never leaked or double-returned even mid-panic), and a recover() around the commit loop itself so one bad job can't take its shard down. New metrics: panic outcome counter, dirty/dropped-connection counters.

Process

Every fix in Lane 0 went through a full Planner→Architect→Critic design review cycle before implementation (Fable-driven, documented in .omc/plans/raw-write-bypass-elimination.md Part C, Revisions 13-20), was implemented by a separate execution pass with fail-on-today verification for every acceptance criterion, and then went through an independent, adversarial final code review that itself re-verified the trickiest claims against the vendored SQLite driver's actual source rather than trusting the design docs. That review's five non-blocking findings (a mislabeled heal-failure metric, a theoretical row-resurrection edge case in the heal, a pre-existing flaky race in an unrelated watch test, one documentation residual, one test-hardening nit) are folded in.

Testing

  • Full pkg/registry/file, pkg/metrics, pkg/apis/softwarecomposition suites pass.
  • -race clean across the whole package and 20x-repeated runs of every new/touched test, including GOMAXPROCS=1 and 16 variants — with one pre-existing, confirmed-unrelated flaky race in TestFileSystemStorageWatchReturnsDistinctWatchers (reproduced on unmodified main) now also fixed as part of this PR (identity comparison instead of a racy deep-equal on live watcher state).
  • Every acceptance criterion across all fixes was verified to fail on pre-fix code and pass post-fix, using stash/isolate/restore or targeted fault-injection (e.g. a SQLite authorizer denying COMMIT to deterministically test the heal's failure path) rather than relying on timing.
  • Not yet re-validated against real kubescape/node-agent CI for the expanded Lane 0 scope — the original single-fix version of this PR was validated that way (13/31 failures, down from 17-18/31 baseline; see prior revision of this description), but that validation predates L0-A/L0-B. A fresh throwaway-image CI run against node-agent is a recommended follow-up before merge, using the same methodology.

Updates docs/features/single-writer-priority-queue.md and docs/features/storage-lock-pool-metrics.md with these findings and fixes.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd

https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd

AI-skills: none

Traced the residual component-tests flakiness (kubescape/node-agent,
15-17/30 jobs failing even after #397's sharded single-writer fix) to
deleteProcessedTimeSeries's DeleteContainerProfile call: it takes a raw
pool connection outside the singleWriter shard system entirely, unlike
SaveContainerProfile (already routed through guaranteedUpdateSingleWriter,
priorityLow) and Create/GuaranteedUpdate's own commits (routed through
the 8-shard system). A raw connection has no way to yield to -- or be
yielded by -- a live shard commit, so a genuine collision for SQLite's
single writer lock blocks the loser for up to the full busy-timeout
(DefaultBusyTimeout, 60s in production) instead of the microsecond
in-process channel wait every shard-routed write already enjoys.

Confirmed locally (containerprofile_load_test.go, LOAD_CONSOLIDATORS=1):
a pure 20-way concurrent write burst alone was flawless (9543/9543 ops,
p99=115ms), but adding one concurrent ConsolidateTimeSeries pass
collapsed throughput by >250x (36 ops/8s, 14 failed, multi-second
"database is locked" stalls) -- the same fast, heterogeneous failure
shape seen in CI, not the original catastrophic hang.

Adds singleWriter.runOnShard(ctx, key, priority, fn): fn runs inside the
key's own shard goroutine, holding the same pool connection and per-key
lock a commit would, so it can't race a live commit for SQLite's lock.
Routes deleteContainerProfileArbitrated (the actual delete call site)
through it. Intentionally does NOT wrap the whole consolidateKeyTimeSeries
unit this way -- an earlier attempt at that self-deadlocked, since
consolidation's own updateProfile already calls SaveContainerProfile for
the SAME key/shard, and that shard's one goroutine would then be waiting
on itself. storageImpl.delete is a safe leaf operation for this: no lock
of its own, no calls back into anything shard- or lock-routed.

Result after the fix, same repro scenario: ~5000+ ops/8s, near-zero
failures, p50 in microseconds, p99 ~5ms (down from 9.9s) -- confirmed
reproducible across independent runs.

Full pkg/registry/file test suite passes, including -race (only the
pre-existing, confirmed-unrelated watch.go race remains, verified against
unmodified main).

Updates docs/features/single-writer-priority-queue.md's "Known gaps"
section with this finding and its fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
@matthyx matthyx added the ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin) label Sep 7, 2026
@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: f6e50f5d-7535-448f-8195-17f530380cf3


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: success

… system

Follow-up to the previous commit (routing ConsolidateTimeSeries's Delete
through runOnShard). Re-tracing real node-agent CI logs after that fix
landed found the SAME raw-connection bypass in AfterCreate's
WriteTimeSeriesEntry call -- fired on essentially every TS ContainerProfile
create, i.e. most of node-agent's write traffic. A synthetic local repro
limited to ContainerProfile writes alone hadn't reproduced this, but real
CI's aggregate write volume across every resource kind sharing this write
path was enough to surface it as "sqlite: step: interrupted" (the caller's
own request context expiring mid-statement on the raw connection) --
initially misread as an unrelated node-agent-side flakiness source, since
the failing tests' own assertions don't mention storage at all.

Two mistakes made while landing this, both caught before committing:

1. First attempt keyed runOnShard on the consolidated (non-TS-suffixed)
   base key. Many distinct TS-suffixed profiles for one container all
   share one base key, so that collapsed traffic that should spread
   across all 8 shards onto whichever few shards those few base keys hash
   to -- p50 went from microseconds to 15s in the load test. Fixed by
   keying on the TS-suffixed profile name instead (the same key its own
   metadata commit already uses), matching the commits' own distribution.

2. createSingleWriter (singlewriter.go) still pre-took a pool connection
   to hand AfterCreate via ctx -- dead weight once AfterCreate stopped
   reading it (no Processor.AfterCreate implementation needs a
   ctx-embedded connection anymore), held uselessly by every concurrent
   caller for the whole call while runOnShard also took its own
   connection, exhausting the pool under load and reproducing the same
   15s-p50 regression. Removed.

Result in containerprofile_load_test.go's LOAD_CONSOLIDATORS=1 scenario,
with both this fix and the previous commit applied: p50=55us, p99~4ms,
only 1/8s of ops over 5s -- an improvement on the previous commit's
already-good numbers (p50 microseconds, p99~5ms, but occasional >5s
stalls remained).

Full pkg/registry/file test suite passes, including -race (only the
pre-existing, confirmed-unrelated watch.go race remains).

Updates docs/features/single-writer-priority-queue.md with this finding,
its fix, and the two mistakes made getting there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: success

@matthyx

matthyx commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Update: reverted the second commit (WriteTimeSeriesEntry routing) from this PR.

A deeper follow-up audit found real problems with both fixes on this branch that I want to be transparent about before anyone reviews further:

  • The reverted fix (WriteTimeSeriesEntry routing) had a confirmed data-loss bug: if the caller's context expires while a runOnShard job is queued, commit()'s lock-wait derives from that same expired context — the metadata commit can succeed while the corresponding time-series row silently never gets written. Since ConsolidateTimeSeries discovers work via ListTimeSeriesWithData, that profile becomes invisible permanently, not just delayed. It also had no defensible CI evidence of improving anything (17/31 → 13/31 → 17/31 across three real-CI validation runs is within that metric's own noise band). Reverted rather than patched in place.
  • The remaining fix (Delete routing) also has a confirmed defect, not yet fixed: storageImpl.delete ends with a synchronous, blocking watchDispatcher.Deleted call, and the watch dispatch channel is effectively unbuffered under load — so a slow watch client can freeze the entire shard goroutine handling that delete for as long as that client stays connected. Create/GuaranteedUpdate deliberately dispatch their watch events in the caller, after the shard commit returns, for exactly this reason; this fix broke that invariant. I'm working on a proper fix (move the watch dispatch back out to the caller) rather than leaving this PR in its current state — will push an update once that's validated, or revert this fix too if the caller-side redesign doesn't hold up.

Recommend holding off on reviewing until that follow-up lands. Sorry for the churn — better to catch this now than after merge.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: success

matthyx and others added 14 commits September 8, 2026 12:31
…t the shard

deleteContainerProfileArbitrated routed StorageImpl.delete through
runOnShard, so the delete's synchronous watchDispatcher.Deleted ran inside
the shard goroutine while it held its pool connection and Lock(key). A "/"
watcher that stops reading blocks send until its own ctx ends, freezing
that shard -- and every job hashed to it, REST included -- for as long as
the remote client chooses.

Split delete into deleteLocked (SQLite + filesystem, no dispatch: a true
leaf) and delete (deleteLocked + dispatch, byte-identical behaviour for
Delete / DeleteWithConn / DeleteContainerProfile). The arbitrated path now
routes deleteLocked and dispatches Deleted on the caller after runOnShard
returns, the createSingleWriter / guaranteedUpdateSingleWriter convention.

runOnShard's contract is stated precisely in its doc comment and in
docs/features/single-writer-priority-queue.md: fn must be a leaf AND
non-blocking, no watchDispatcher.* inside fn.

TestDeleteArbitrated_StalledWatcherDoesNotFreezeShard fails at 0f75129
(the same-shard probe hits its 2s deadline) and passes after this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…ck (PRE-3)

processTimeSeriesInTransaction called endFn(&err) inline after updateProfile.
A panic raised inside updateProfile -- after the first series' Replace DELETE
had taken SQLite's write lock inside the pass's deferred transaction --
escaped with the transaction open, and the connection went back to the pool
with nobody left to end it. Every later writer then waited on that lock for
the full busy timeout (SQLITE_BUSY).

endFn is now deferred directly: sqlitex's endFn recovers the panic, rolls the
transaction back and re-panics, so the panic still propagates (PRE-3 fixes
the lock, not the crash) but the write lock is released. A second deferred
closure preserves the error wrapping for both a failed updateProfile and a
failed COMMIT.

TestProcessTimeSeriesInTransaction_PanicLeavesNoOpenTransaction injects the
panic from the second series' TS read (after the first series' DELETE) and
asserts a write on another connection succeeds immediately. It fails at
9cb7ce6 with "database is locked" and passes after this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
… (finding T)

updateProfile's gate 1 (no InstanceID annotation on the merged profile, so
the observed save has no target) returned the merged TS keys as processed
while persisting nothing. consolidateKeyTimeSeries then deleted those TS
objects, losing the data they carried.

INV-PROCESSED: a tsKey is returned only when the profile it was merged into
was persisted by this pass (or, after finding X, reclaimed unmerged by the
frozen gate). Gate 1 now returns nil, nil.

TestUpdateProfile_MissingInstanceID_ProcessedIsNil builds its TS profile
without an InstanceID annotation (with one, mergeContainerProfileTS would put
the key on the profile and gate 1 would be unreachable). It fails at 9cb7ce6
(processed names the merged key) and passes after this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…inding U)

mergeTimeSeriesData appended every row's suffix to the delete list before
reading its object, so a row whose read failed with a transient (non-NotFound)
error was still collapsed into its neighbour by consolidateContinuousTimeSeries
and then deleted by Replace with its object never merged: data loss. When that
row was the chain head, the terminal branch fired on the unmerged row and the
profile was stamped Completed/Full without its data (U-b).

The transient arm now neither appends the suffix nor hands the row to the
consolidation: mergeTimeSeriesData returns kept (the input minus the
transient rows, carrying the loop's HasData=false mutations) and
processTimeSeries consolidates kept. The chain is consolidated around the gap,
the row stays in the table with HasData=true and is retried next tick, when
the chain collapses normally. The other three arms are unchanged. A series
whose rows all took the transient arm returns before updateProfileStatus,
which indexes newTimeSeries[0] unconditionally. The retry is logged at
Warning (was Debug).

Not appending alone is not enough: the chain would still be collapsed across
the row, the two sides would fork on every later tick and the profile could
only finish Completed/Partial through the expired path.

Tests (fail at 9cb7ce6, pass after): U-1 non-head transient row survives and
heals without a fork; U-2 head transient row does not complete unmerged; U-4
all-transient series runs no statement and does not panic. U-1 and U-2 also
fail on the not-appending-only variant. Pins: U-3 (a collapsed HasData=false
row is deleted -- fails if the !HasData arm drops its append), U-5 (a
permanently failing read keeps its row and the key enumerated).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…finding X, E3)

"Once a profile is completed/full nothing should update it" was enforced at
the REST boundary (PrepareForUpdate resets the update) and at TS admission
(PreSave refuses the Create), but consolidation's own save was outside the
contract: SaveContainerProfile's tryUpdate ignored the persisted object it
was handed under Lock(key) and wrote whatever the pass had merged. A row from
another replica's series, a row that landed after the completing pass, or a
row admitted by PreSave's unlocked read was merged on a later tick into a
Completed/Full profile and dispatched as Modified.

Two layers, both on the persisted state:

X-A, the frozen gate at the top of updateProfile: when the payload the pass
read is Completed/Full, every listed row is reclaimed with the existing
(seriesID, tsSuffix IN listed)-scoped Replace delete and every HasData object
is scheduled for deletion; no TS object is read, nothing is merged, nothing
is saved. Counted (storage_consolidation_frozen_reclaimed_total{what}), one
Warning per key per tick. The gate reads the persisted state, never the copy
the loop is about to stamp, so the completing tick always passes.

X-B, the refusal inside the write: tryUpdate returns ProfileFrozenError when
the object GuaranteedUpdateWithConn read under Lock(key) is Completed/Full.
The pass's transaction rolls back and the next tick takes X-A. Counted
(storage_consolidation_frozen_refusals_total, expected zero).

A frozen tick sends no consolidated slug. The predicate is captured BEFORE
the pass: profile is passed by value, but its Annotations map is shared by
every copy, so evaluating it after the pass -- as the design text assumed --
would have made the completing tick look frozen and the guard "never send".
X-2 catches that.

E3, the divergence heal. saveObject renames the payload into place and the
row commits afterwards, so a process crash or a failed COMMIT between the two
leaves the payload Completed/Full at n+1 while the row (what LIST, WATCH and
PreSave read) still says Learning at n. Today that heals itself by re-merge
on the next tick (duplicating Spec); under X-A alone it would never heal --
the gate would reclaim every row while the row stayed Learning for the
container's life and PreSave kept admitting reports. consolidateKeyTimeSeries
now reads the metadata row in autocommit before the pass's transaction; on
payload-ahead it calls HealDivergence, which takes Lock(key), opens a
BEGIN IMMEDIATE (so an in-flight completer's COMMIT has landed before the
re-read), re-reads the row, and re-persists the payload as-is through
saveObject -- RV+1, no merge, no new data -- then dispatches the lost
Modified after COMMIT. Counted (storage_consolidation_divergence_total
{payload_ahead}); a failing heal fails the tick before X-A and is counted
by step (storage_consolidation_heal_failed_total{reason}). The inverse
shape, metadata-ahead (a lost rename after a power loss, B16), is counted
and warned only; today's merge-and-re-save proceeds and PreSave's revert
restores Completed from the row, RV colliding with the row's own n+1.

softwarecomposition.IsCompletedFull is the one predicate, shared by the
status setters and every gate above.

Tests. Fail on today's behaviour, pass after: X-1 (frozen base reclaims rows
and objects, nothing read or written, zero events, no slug; active, expired
and single-writer-off), X-3 (a base completed on another connection between
the pass's read and its write is refused, the transaction rolls back, the
next tick reclaims), E3-1 (payload-ahead healed without merge: row and
payload at n+2, Spec byte-equal, one Modified received after COMMIT, then
X-A reclaims in the same tick; also fails on X-A without E3), E3-2
(metadata-ahead counted, then today's re-save pinned: Learning handed to the
save, Completed restored by PreSave, Completion from the row, RV n+1
colliding). Pins: X-2 (the completing tick is not refused and sends one
slug), X-4 (Completed/Partial is not frozen), X-5 (an unlisted row survives
a frozen tick and is reclaimed next), E3-3 (a concurrent completer wins:
the heal waits at BEGIN IMMEDIATE with Lock(key) held, writes nothing),
E3-4 (a heal that fails after its own rename converges on the next heal),
and the predicate table test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…PC-TS-4)

updateProfileStatus's two terminal branches (expired and active Completed/
Full) ran DeleteTimeSeriesContainerEntries -- DELETE FROM time_series WHERE
kind/namespace/name, no seriesID, no tsSuffix -- from inside the consolidation
transaction. That statement was safe only if every row under the key had
already been merged, which is false twice over: updateProfile merges one
series per iteration and the terminal branch breaks the loop, so the series
later in map order were deleted unmerged; and the pass's list runs before
its transaction, so a row committed in between was deleted too. Every row
it removed had its TS object orphaned on disk (the objects were not in
processed), and the rows it removed belong to a profile the same pass stamps
Completed/Full, which under finding X must never be merged anyway.

Delete both calls and add nothing: processTimeSeries already calls
ReplaceTimeSeriesContainerEntries unconditionally afterwards, and on a
terminal branch updateProfileStatus returns an empty newTimeSeries, so
Replace's (seriesID, tsSuffix IN listed)-scoped DELETE removes exactly the
rows the pass read for that one series and inserts nothing. Rows of an
unreached series, and rows that landed after the list, survive to the next
tick, where the frozen gate reclaims them WITH their objects, counted.
updateProfileStatus loses its ctx, its storage receiver and its error
return: a pure function that cannot execute SQL. The interface method that
only it called is removed; the package-level function keeps its two REST
callers. The FIXME at ReplaceTimeSeriesContainerEntries now states that its
DELETE must stay list-scoped and is the only time_series delete on this path.

seriesOrder is a test seam beside consolidateKey: nil keeps map order in
production; TS4-B forces A before B so the terminal branch's unreached
series is chosen, not inherited from map iteration.

TS4-B (TestConsolidate_TerminalBranch_LeavesUnreachedSeriesIntact) fails with
the whole-key delete present (B's row is gone after tick 1) and passes after:
B's row and object survive tick 1, and tick 2 reclaims B unmerged with RV,
Spec and annotations unchanged and zero events. TS4-C is a pin of the
non-terminal branch against a future whole-key statement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…connection counters

storage_single_writer_commit_total gains outcome="panic" for a commit the
shard goroutine's recover converted to an error, and two counters back the
connection-clean check on the shard's pool release:
storage_single_writer_dirty_connection_total (rolled back before reuse) and
storage_single_writer_dropped_connection_total (could not be rolled back;
the pool shrinks by one). All three must stay zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…r (L0-B layer 1)

runOnShard's closure ran bare on the shard goroutine, so a panic in it
unwound the shard and, with no recover() anywhere in the package, exited the
process. callGuarded recovers the closure's panic and returns it as an error
(with the stack in the message), so the job fails to its own caller, the
per-key lock and pool connection are released by commit()'s defers as on any
error, and the shard takes the next job.

TestSingleWriter_CustomPanic_CallerGetsErrorShardSurvives crashes the test
binary on the previous commit (panic: injected custom panic) and passes here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…cs (L0-B layers 2+3)

Two shipped hazards in the shard commit loop, one mechanism split across two
frames because Go unwinds commit()'s defers before process() sees the panic:

Layer 2 -- putChecked replaces commit()'s `defer s.pool.Put(conn)`. commit()
calls release(&err) normally, not deferred (so a failed rollback's error is
not lost), so a panic in writeMeta/renamePayload skipped it and returned a
connection with an open SAVEPOINT to the pool; Pool.Put only checks stepped
statements. putChecked runs on every exit, removes the temp payload on any
non-committed exit, tests AutocommitEnabled and CheckReset, rolls back /
resets a dirty connection before reuse (dirty_connection_total) and drops
one it cannot clean instead of letting Put panic during the unwind
(dropped_connection_total). It calls no recover() of its own; the defer is a
closure so committed is read at execution time.

Layer 3 -- process() recovers a panic that escapes commit() (sqlitex.Save's
release panics on a failed ROLLBACK TO, outside callGuarded's reach),
delivers errCommitPanic to the caller, counts outcome=panic, logs the stack,
and takes the next job. Before this an unrecovered panic on the shard
goroutine exited the process, and a caller waiting on that shard would
never have received a result.

Tests (each crashes the binary on the previous commit; run one per process
for the fail-on-today evidence):
- CustomPanicWithSteppedStatement_ConnectionIsReset: layer 2's CheckReset
  branch (Put would have panicked "active statement" during the unwind)
- WriteMetadataPanic_ShardSurvivesConnectionRolledBack: the dangling
  savepoint is detected and rolled back, temp payload gone, key reusable
- ReleasePathPanic_ShardSurvives: release's own panic, from commit()'s frame
- SuccessfulCommit_DoesNotRemoveRenamedPayload: discriminates the
  argument-at-defer-time bug (verified to fail against `defer w.putChecked(...)`)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…nt row alone (E3-5, E3-6)

HealDivergence deferred endFn(&err) on the closure's named error, so a
COMMIT that failed after the payload rename surfaced as the raw SQLite
error and healFailureReason fell through to "unknown" -- a label outside
the documented set, for exactly the I/O-class failure the heal exists
to repair. The deferred end now wraps any error that is not already a
healFailure as reason "commit" (a ROLLBACK failure panics in endFn, so
that is the only shape left), and the metrics doc names it.

The same function treated a NotFound row as healable and would have
re-persisted the payload -- resurrecting a key a crash between the
row's delete and the payload's remove had already deleted. The caller's
pre-check never asks for a heal on that shape; HealDivergence now
returns nil on it too, mirroring the caller, instead of relying on it.

E3-5 pins the failed-COMMIT label (an authorizer denies the COMMIT at
prepare time on the pass's own connection; the row rolls back, the
payload lands one version further ahead, the next heal converges) and
E3-6 pins the absent-row no-op (row not resurrected, payload untouched,
nothing dispatched, nothing counted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…t-watchers test

TestFileSystemStorageWatchReturnsDistinctWatchers asserted
assert.NotEqual on two live *watcher values, which reflect.DeepEquals
their fields while each watcher's shipIt goroutine is running -- a data
race the detector reported in most runs (16 reports in 20 on the base
commit). The test's intent is that Watch returns a new object each
call; assert.NotSame checks exactly that, by pointer, and reads nothing
the goroutine writes. Pre-existing; not introduced by Lane 0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
…ter heal test (E3-3)

TestHealDivergence_ConcurrentCompleterWins slept a fixed 300ms and then
probed Lock(key) with a 50ms timeout, assuming the heal goroutine had
reached Lock(key) by then -- a starved runner could fail the probe
spuriously. The probe is now a require.Eventually that polls until the
lock is held (releasing immediately whenever the probe wins), and the
elapsed-time assertion that only restated the sleep is dropped; the
"heal must not return while the completer's transaction is open" check
stays, on a bounded wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
@matthyx matthyx changed the title fix: route ConsolidateTimeSeries's Delete through the write-shard system fix: Lane 0 — close the write-path shard bypasses and immutability gaps Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: failure

@matthyx

matthyx commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Real-CI validation: no measurable improvement over same-day baseline (n=3 each)

Validated this branch (Lane 0, commit ddf33dc2) against node-agent's component-tests suite via 6 throwaway node-agent PRs, run the same day for an apples-to-apples comparison: 3 runs of this branch's image and 3 runs of an image built from kubescape/storage main (commit 4535872c, pre-Lane-0, PR #397 merged) — same node-agent test-suite snapshot both times.

Failure counts (out of 31 component-tests jobs):

  • Lane 0 (this branch): 19, 15, 14 failed → mean 16.0, range 14-19
  • Same-day main baseline: 15, 16, 19 failed → mean 16.7, range 15-19

The two distributions overlap almost completely and are not distinguishable at this sample size — Lane 0 is not clearly better or worse than baseline on job-level pass/fail counts.

Per-test breakdown: 8 of 31 tests fail in every single one of the 6 runs regardless of which storage image was used (Test_02_AllAlertsFromMaliciousApp, Test_06_KillProcessInTheMiddle, Test_11_EndpointTest, Test_14_RulePoliciesTest, Test_27_ApplicationProfileOpens, Test_28_UserDefinedNetworkNeighborhood, Test_32_UnexpectedProcessArguments, Test_36_MultiContainerPerContainerBinding), plus 3 more that fail in 5 of 6 runs (Test_17_ApCompletedToPartialUpdateTest, Test_30_IgnoreExcludeAndLearningDuration, Test_49_EphemeralContainerFullTreatment). This ~11-test core is the dominant failure source in both baseline and Lane 0 and looks environmental/harness-related (CI runner resource pressure, timing assumptions in the kind cluster), not specific to either storage write path. (Initially flagged Test_17 as possibly Lane-0-specific after baseline's first run missed it — resolved once baseline reached n=3: it shows up in 2 of 3 baseline runs too.)

Storage-side signal: pulled the storage pod's own logs (not just node-agent's) for a 6-job sample from one Lane 0 run and one baseline run. Lane 0 shows a real but modest reduction in apiserver Handler timeout volume (mean 427.5/job vs baseline's 554.0/job, ~23% fewer) and somewhat fewer literal database is locked/SQLITE_BUSY/step: interrupted occurrences (mean 2.8/job vs 4.8/job). So there's evidence the underlying write-contention issue this branch targets is somewhat reduced — it just isn't large enough, relative to the ~11-test environmental-flake core, to move the end-to-end job-level failure count outside baseline's own noise.

Net assessment: no clear regression, no clear improvement in CI outcomes as currently measured. The component-tests suite's aggregate pass/fail signal is dominated by failures unrelated to the storage write path, which limits how visible any storage-side fix can be at this level — a more targeted signal (e.g. direct measurement of Handler-timeout / lock-error volume, as sampled above, or the local containerprofile_load_test.go benchmarks referenced in earlier iterations of this validation) is likely more informative than the job-pass-count for judging this fix.

Throwaway validation PRs (closed after data collection, branches kept): kubescape/node-agent#956, #957, #958, #959, #960, #961.

@matthyx

matthyx commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Step 1-L measurement: Tier B paired A/B of this branch vs its merge-base

Measured with the new two-tier storage measurement harness (make perf-ab, branch feat/step1-l-measurement-harness, to be opened as its own PR): ddf33dc2 (this branch) vs 4535872c (merge-base with main), same machine, same window, 10 interleaved base/head rounds after a 3-round noise probe, pinned to 12 cores / GOMAXPROCS=8, production shape (pool 10, 8 shards, Workers 2), fixed work per round (6 writers × 2400 Creates, 25 readers × 12000 Gets, 3 updaters × 1200 GuaranteedUpdates, consolidation ticking every 250 ms), effective config verified identical on both arms. Primary statistic is a paired t-test on per-round log-ratios; Mann–Whitney U is the second opinion.

Verdict line: REGRESSION over-five-sec head=17 base=0 (hard row: any on HEAD when BASE has none) — accepted as a known, explained trade-off; not treated as a blocker for this PR (see reading below).

metric base head effect t-p mw-p s_d% MDE% threshold verdict
get-p99-ms 2.798 2.002 −28.3% 0.000 0.000 6.3 6.5 >+20% PASS
create-p99-ms 133.410 57.017 −56.8% 0.000 0.000 16.0 17.2 >+20% PASS
update-p95-ms 8.288 6.397 −22.1% 0.001 0.001 17.2 18.6 >+20% PASS
update-p99-ms 210.249 25.163 −83.7% 0.000 0.000 none info
tick-p50-ms 187.094 1313.642 +597.0% 0.000 0.000 12.6 13.4 >+20% REGRESSION
tick-p99-ms 795.754 5147.996 +541.1% 0.000 0.000 19.0 20.9 >+30% REGRESSION
ops-per-s 15004 17197 +14.8% 0.001 0.000 8.3 8.6 <−10% PASS
lock-wait-timeouts 0 0 sum 0→0 1.000 1.000 head>base PASS
pool-wait-timeouts 562.5 127.9 sum 5625→1279 0.000 0.000 head>base PASS
over-one-sec 0.7 4.3 sum 7→43 0.000 0.000 head>base REGRESSION
commit-conflict-rate-pct 0 0 +0.00pp 1.000 1.000 >+5pp PASS
err-other 0 0 sum 0→0 any PASS (hard)
over-five-sec 0 17 sum 0→17 any REGRESSION (hard)
commit-panic 0 0 sum 0→0 any PASS (hard)

Reading. REST operations improve dramatically and significantly under contention: Get p99 −28%, Create p99 −57%, GuaranteedUpdate p95 −22%, pool-wait timeouts −77%, throughput +15%, all p < 0.001, no lock timeouts, no conflicts, no errors. Consolidation ticks get measurably slower: each processed TS row now costs a synchronous shard round-trip (pool take + Lock(key) + DELETE on the shard, then the Deleted event on the caller — commit_total{priority=low} = 14400 per round, exactly one per Create), so the per-row consolidation cost rises and, with fixed-interval ticking, a slower pass accumulates more rows for the next one (ticks per round 40 → 7, ~360 → ~2000 rows per pass). The +597%/+541% tick percentiles are largely that batching feedback; the over-five-sec hard row fired on legitimately long ~2000-row passes, not on a wedge — there is no busy-timeout or deadlock signature (err-other 0, commit-panic 0, lock-wait-timeouts 0).

This is a real, quantified instance of exactly the row-vs-payload-split overhead that the separate full-ACID redesign effort (in progress) is expected to eliminate. Recorded here so the cost is on the record alongside the correctness fixes; the Tier A work-budget diff for this branch (per-scenario statement/lock/take deltas) will be carried in the harness PR's description.

@matthyx
matthyx merged commit 82ba955 into main Sep 8, 2026
6 of 7 checks passed
@matthyx
matthyx deleted the fix/route-consolidation-delete-through-shard branch September 8, 2026 19:58
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: success

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin)

Projects

Status: To Archive

Development

Successfully merging this pull request may close these issues.

1 participant