fix(storage): shard single-writer commits by key hash - #397
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: 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. Comment |
|
Summary:
|
Empirical validation against kubescape/node-agent's real CIBuilt a test image from this branch and validated end-to-end against node-agent's actual Default (8 shards, pool size 10) — node-agent#949:
Higher headroom (16 shards, pool size 24) — node-agent#950:
Conclusion: this PR fixes the actual regression (the single-writer-by-default throughput collapse). A smaller, separate flakiness remains in node-agent's CI that doesn't respond to more storage-side concurrency and likely needs its own investigation (CPU limits, or possibly unrelated CI pod churn observed during validation) — tracked independently, not blocking this fix. 🤖 Generated with Claude Code |
4023d88 to
0a0361d
Compare
The single-writer write path (default-on since #393) funneled every Create/GuaranteedUpdate/SaveContainerProfile commit for ALL keys through one goroutine per StorageImpl, collapsing write concurrency from the legacy per-key-lock path's ~SqlitePoolSize-way parallelism to strictly 1-way. kubescape/node-agent's CI went from 0-1 flaky failures to 18-19 of ~30 jobs failing, every one on context deadline exceeded against the storage apiserver. Correctness only ever required one in-flight committer per key: commit() re-acquires the per-key lock and does an authoritative resourceVersion compare-and-commit, and writes to different keys touch disjoint metadata rows and payload files. Route commit jobs to N=8 shard goroutines by FNV-1a over the key instead, so same-key commits stay serialized in submission order while different-key commits run in parallel. highBurstLimit's high-vs-low arbitration becomes per-shard. Jobs on different shards never competed for a goroutine's time, so there is nothing there to arbitrate; only same-shard cross-priority contention is affected. Shard count is a fixed part of what SingleWriterEnabled turns on (DefaultSingleWriterShards, 8), not independently configurable -- validated empirically against kubescape/node-agent's real CI, which showed raising it further (16, with pool size 24) doesn't reduce the residual failure rate over the default, so exposing it as a tunable wasn't worth the added config surface. Empirical validation: with this fix, node-agent's CI went from 18-19/30 failing (every one a sustained 20-minute hang against the storage apiserver) to 15/30 (fast, self-healing failures on unrelated assertions; one representative test run went from 323 failed create attempts to 6 out of 61). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLN3CgGftcnAikV13qD6yJ Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
0a0361d to
880b6b1
Compare
|
Summary:
|
Traced the residual component-tests flakiness (kubescape/node-agent, 15-17/30 jobs failing even after kubescape#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
Summary
The single-writer write path (#393, default-on since v0.0.331) funnels every
Create/GuaranteedUpdate/SaveContainerProfilecommit for all keys through exactly one goroutine perStorageImpl. That collapsed write concurrency from the legacy per-key-lock path's ~SqlitePoolSize-way parallelism (default 10) down to strictly 1-way for the entire resource kind.This is a measured, confirmed regression: kubescape/node-agent's CI (a write-heavy, high-key-cardinality workload — many
ContainerProfileobjects for many pods written near-concurrently) went from 0-1 flaky test failures to 18-19 of ~30 test-suite jobs failing after the storage image bump that picked up v0.0.331, every one hanging oncontext deadline exceededagainst the storage aggregated apiserver, sustained for the full ~19-minute test timeout.containerprofilesgets its own dedicatedStorageImpl/writer instance (pkg/apiserver/apiserver.go), and 100% of the timeouts traced back to that endpoint specifically. This repo's own docs already flagged the risk before #393 flipped the default:docs/features/single-writer-priority-queue.md's "Known gaps" section said plainly "No throughput ceiling has been measured under deliberate load."Fix
Correctness only ever required one in-flight committer per key —
commit()already re-acquires the existing per-key lock and does an authoritative resourceVersion compare-and-commit specifically because writes to different keys touch disjoint metadata rows and payload files. A single global writer goroutine was therefore stricter than the design actually needed.This PR routes each commit job to one of
Nshard goroutines (config.Config.SingleWriterShards, default 8, tunable) by hashing the key (FNV-1a, pure/deterministic, no allocation). Same key → same shard → same serialization guarantee as before (no-lost-updates, torn-read prevention, delete-race conflict detection — all unchanged, verified by the full existing test suite still passing unmodified except one deliberately-adjusted test, see below). Different keys that land on different shards now commit in parallel again.commit()itself is untouched — only the routing above it changed.Deliberately weakened, and why that's fine:
highBurstLimit's high-vs-low fairness arbitration becomes per-shard rather than global. Jobs on different shards never competed for a goroutine's time in the old design either — there's nothing to arbitrate between jobs that were never contending. Only same-shard cross-priority contention is affected; within a shard the guarantee is exactly what it was before.Changes
pkg/registry/file/singlewriter.go—singleWriterbecomes a router over NwriterShards, each with its own high/low channels and its own goroutine running the same fairness loop. NewDefaultSingleWriterShards(8),singleWriterShards,SetSingleWriterShards,shardIndexFor(pure FNV-1a hash → shard index),queueDepth(sums shard depths for the existing per-priority metric so it doesn't flap between shards).pkg/config/config.go—SingleWriterShardsfield, defaults to 8 (hardcoded with a "keep in sync" comment, matching howsingleWriterEnabled/poolTimeoutalready avoid thefile↔configimport cycle).main.go— wiresfile.SetSingleWriterShards(cfg.SingleWriterShards)alongside the existing setters.pkg/registry/file/singlewriter_test.go:TestSingleWriter_PriorityOrderingnow pinsshards=1for that specific test (via a newsetSingleWriterShardshelper) — its assertion is inherently about cross-key ordering on ONE committer, which sharding intentionally no longer guarantees; forcing 1 shard preserves the test's original intent.TestSingleWriter_ConcurrentCommitsDifferentKeys_ShardsRunInParallel: proves actual commit-phase parallelism across shards (peak concurrent commits > 1, and wall-clock well under the fully-serial bound). Verified this fails against the pre-fix single-goroutine behavior (peak=1) and passes with the fix.TestShardFor_Deterministic: same key always routes to the same shard; a reasonable key set spreads across more than one shard.pkg/config/config_test.go— asserts the new default.docs/features/single-writer-priority-queue.md— updated for the sharded design; also corrected two stale lines (title/summary still said "prototype, gated off" / "default false", both stale since feat(storage): enable single writer by default and harden pool timeout #393).Test plan
go build ./...,go vetclean on touched packages (pre-existing, unrelatedunkeyed fieldsvet noise inpkg/registry/file/callstackconfirmed present on unmodifiedmaintoo)go test ./pkg/registry/file/ -race -run 'TestSingleWriter|TestShardFor' -v— all pass, including the new testsgo test ./pkg/registry/file/... -race -run TestSingleWriter -count=5— clean, no flakinessgo test ./... -race— green except the pre-existingTestFileSystemStorageWatchReturnsDistinctWatchersrace, independently confirmed to reproduce identically on unmodifiedmain(unrelated to this change — it's in theWatch()path, not the write path)Context
Diagnosed via kubescape/node-agent's
component-testsCI (dozens of ContainerProfile writes/reads across concurrently-starting pods), by directly tracing this code at the deployed version (v0.0.331) and confirming with a live before/after comparison. Also independently ruled out an alternative (CUSTOM_REST_ENABLED, #387) empirically — it shares the sameStorageImpl/single-writer instance as the default REST path, so it doesn't touch this bottleneck; a CI run confirmed no improvement.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01LLN3CgGftcnAikV13qD6yJ
AI-skills: none | cmds: /oh-my-claudecode:autopilot