Skip to content

fix(storage): consolidation self-stall on the CollapseConfiguration settings cache - #401

Merged
matthyx merged 3 commits into
mainfrom
fix/collapse-settings-self-stall
Sep 8, 2026
Merged

fix(storage): consolidation self-stall on the CollapseConfiguration settings cache#401
matthyx merged 3 commits into
mainfrom
fix/collapse-settings-self-stall

Conversation

@matthyx

@matthyx matthyx commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a consolidation goroutine self-stalling on its own held SQLite write lock for up to the full busy-timeout (60s in production) via an unrelated settings-cache refresh — confirmed as the dominant driver of kubescape/node-agent's component-tests CI flakiness (real-CI validation dropped failures from a 15-19/31 baseline to 0-1/31 with a workaround alone).

Overview

A consolidation goroutine holding SQLite's write lock (inside its own deferred transaction) could self-stall for up to the full busy-timeout (DefaultBusyTimeout, 60s in production) via an unrelated settings-cache refresh.

Root cause: NewCRDCollapseSettingsProvider's cache (10s TTL) is refreshed from inside ContainerProfileProcessor.PreSave, which itself runs inside consolidation's open transaction on connection A. The refresh calls Get on a second pool connection B. When no CollapseConfiguration custom resource exists, that read's underlying get() finds no payload file and issues a DeleteMetadata on connection B — which needs the same SQLite write lock connection A already holds, on the same goroutine. SQLite's busy handler retries for the full timeout before giving up.

This fires on essentially every consolidation tick with data whenever no CollapseConfiguration CR exists (node-agent's REST Create returns from PreSave before this point, so only consolidation saves trigger a refresh; the 30s consolidation interval exceeds the 10s TTL, so nearly every tick hits an expired cache). Live since v0.0.291 (2026-06-26) — this predates and is independent of the write-shard/single-writer work in #397/#399.

Real-world confirmation: adding a CollapseConfiguration resource to kubescape/node-agent's component-tests chart (zero storage code changes) dropped CI failures from a 15-19/31 baseline to 0-1/31 across three independent runs — this is very likely the dominant driver of the flakiness that motivated the broader write-path investigation.

Fix

Two independent commits, each with its own fail-before/pass-after tests:

  • 957af4cdNewCRDCollapseSettingsProvider becomes stale-while-revalidate: primed synchronously at construction, hot path is a single atomic load, refresh runs in a CAS-guarded background goroutine. No pool/lock/statement work ever happens on the caller's goroutine again.
  • e3429c2fget() checks ReadMetadata before issuing DeleteMetadata on a missing payload, so a read of an absent key is a SELECT, never a write-lock-requiring DELETE (defense in depth; orphan-pruning behavior for a key whose row does exist is unchanged).
  • bf690e36 — test hygiene follow-up from independent review: removes a latent -race flake (a shrunk lockTimeout package var the background refresh could read unsynchronized) by joining the refresh explicitly instead.

How to Test

go test -race -count=20 ./pkg/registry/file/ -run 'Collapse|Consolidate|TestGet_'

All new tests fail deterministically on unfixed code at ~the busy-timeout duration and pass after each corresponding fix; AC-B1 still fails with only Fix A applied, confirming both fixes are independently necessary.

Additional Information

Went through an independent adversarial code review (verdict: SHIP, no blocking findings) that re-derived every claim from source rather than trusting the design doc, including tracing the CAS-guarded refresh for genuine race-freedom and checking all six callers of get() individually. Full root-cause chain, design rationale, and review are recorded in docs/features/collapse-settings-self-stall.md.

git merge-tree against current main (which now includes #399) reports zero conflicts.

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

https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd

AI Review

Independent code-review subagent (Claude, Fable model) · verdict: SHIP, no blocking findings. Re-derived every claim from source rather than trusting the design doc: reproduced the fail-before timings independently in scratch worktrees, traced the CAS-guarded background refresh for genuine race-freedom, and checked all six callers of get() individually to confirm none depend on the removed DELETE side effect. Two minor test-hygiene items found (both latent -race flakes in test code, not production code) — addressed in bf690e36.

Ticket

None

matthyx and others added 3 commits September 8, 2026 21:26
…s goroutine

A consolidation save calls PreSave from inside processTimeSeriesInTransaction,
on a connection that already holds SQLite's write lock (the pass has run
ReplaceTimeSeriesContainerEntries). PreSave calls the CollapseSettings
provider; when its 10s cache had expired the provider did a storage Get on a
SECOND pool connection, and with no CollapseConfiguration CR present get()'s
missing-payload DeleteMetadata on that connection needed the write lock the
same goroutine holds. It waited in SQLite's busy handler for the full busy
timeout (DefaultBusyTimeout, 60s), holding the WAL writer lock the whole time:
every shard commit and every other consolidation worker queued behind it.

Nothing else refreshes this cache in production (TS profile Creates return
from PreSave before the provider call) and the consolidation interval (30s)
exceeds the TTL (10s), so this fired on every tick that had data to
consolidate, whenever no CR was applied. Live since 2ea734d (v0.0.291);
6abe45d's cache reduced it from per-save to per-tick.

Make the provider stale-while-revalidate: prime the cache synchronously at
wiring time (no transaction is open there), serve every later call from one
atomic load, and run the refresh in a CAS-guarded background goroutine. The
caller's goroutine never takes a connection, a lock or a statement. Staleness
becomes TTL plus one call; the two tests that asserted "the very next call
reflects the edit" now use Eventually. The TTL is captured once at
construction so the background refresh never reads the package var.

Acceptance (fail before / pass after, real pool, 2s busy timeout):
- TestCRDCollapseSettingsProvider_NoStorageIOUnderHeldWriteLock: 2.004s -> <500ms
- TestCRDCollapseSettingsProvider_NoLockWaitOnCallerGoroutine: 1.000s -> <500ms
- TestConsolidateTimeSeries_DoesNotStallOnCollapseRefresh: 2.005s -> <500ms

Design: .omc/plans/collapse-settings-self-stall.md
Docs: docs/features/collapse-settings-self-stall.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCMGT6Po2tSr1VEDVrbbYd
get() pruned the metadata row whenever the payload file was missing, without
checking that a row exists. A DELETE that matches no row still opens a write
transaction, so every read of an absent key acquired SQLite's write lock and
waited behind any in-flight writer for up to the busy timeout (60s), which
the C busy handler does not interrupt. This is the statement that turned the
CollapseConfiguration refresh into a self-wait (previous commit) and it makes
REST GETs of missing keys queue behind long writers.

Check ReadMetadata first and delete only an existing orphaned row. A read of
an absent key is now a SELECT, which never waits on a writer in WAL mode.
Orphan pruning (a51d55f) is unchanged; the corrupted-payload branches, where
a row is expected, are untouched.

Acceptance (fail before / pass after, real pool, 2s busy timeout):
- TestGet_AbsentKeyDoesNotWaitOnWriter: 2.004s -> <500ms
- TestGet_PrunesOrphanedMetadataRow: guard, passes before and after

Docs: docs/features/collapse-settings-self-stall.md

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

Review follow-up on the self-stall tests. Two latent -race shapes:

- NoLockWaitOnCallerGoroutine shrank lockTimeout and restored it in
  t.Cleanup while the background refresh reads it in acquireLockedConn.
  pool.Close (registered earlier, so it runs later in LIFO order) joins
  only a goroutine that already holds a connection, and the read happens
  before pool.Take, so a late-scheduled refresh could race the restore --
  the same shape as the collapseSettingsTTL race fixed in the provider.
  Leave lockTimeout at its default: it only bounded the pre-fix failure
  (now 5.004s instead of 1.000s), nothing post-fix needs it.
- All three tests relied on pool.Close to join the refresh. Wrap the real
  storage in a Get-counting shim and require.Eventually the prime plus one
  refresh have completed before the test returns, after the timing
  assertions so the pre-fix failure is still reported as the stall.

Re-verified: fixed tree 20x under -race clean; on unfixed origin/main the
three tests fail on the timing assertion (2.003s / 5.004s / 1.980s).

Docs: docs/features/collapse-settings-self-stall.md

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 8, 2026
@coderabbitai

coderabbitai Bot commented Sep 8, 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: 592f815c-551a-4666-9eff-3b50ee4d157b


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.

@matthyx

matthyx commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

cc @entlein

@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

@matthyx matthyx added the release label Sep 8, 2026
@matthyx
matthyx merged commit 6386bf2 into main Sep 8, 2026
6 of 7 checks passed
@matthyx
matthyx deleted the fix/collapse-settings-self-stall branch September 8, 2026 20:21
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) release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant