Skip to content

fix(containerprofilemanager): repair timestamp chain on LRU eviction and retry exhaustion - #884

Merged
matthyx merged 3 commits into
kubescape:mainfrom
aryanghai12:fix/containerprofile-queue-drop-lineage
Aug 7, 2026
Merged

fix(containerprofilemanager): repair timestamp chain on LRU eviction and retry exhaustion#884
matthyx merged 3 commits into
kubescape:mainfrom
aryanghai12:fix/containerprofile-queue-drop-lineage

Conversation

@aryanghai12

@aryanghai12 aryanghai12 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Overview

  • Current Behavior: enforceMaxSize (LRU eviction) and MaxAttempts retry exhaustion dropped queued chunks without replacement, breaking the container's (previousReportTimestamp, reportTimestamp) chain. Storage requires an unbroken chain tracing back to the container's first report to complete a profile; dropping a chunk caused the affected container to remain stuck in Learning mode indefinitely with no visible errors.
  • Future Behavior: Both paths now use the stitch-repair mechanism introduced in fix(containerprofile): split oversized chunks on HTTP 413 instead of ending learning #866 to preserve timestamp continuity. MaxAttempts exhaustion triggers dropChunk(dropReasonMaxAttemptsExhausted), and LRU evictions in enforceMaxSize enqueue a metadata-only stitch chunk. Stitch creation is bounded to 10% of MaxQueueSize (maxStitchBacklogFor) to prevent infinite queue growth during eviction, falling back to dropReasonLRUBacklogExhausted when full.

Additional Information

  • Stitch Refactoring: Factored out duplicated stitch-construction logic across dropChunk, requeueSplit, and enforceMaxSize into a shared newStitchFor helper function.
  • Metrics & Observability: Introduced metric labels (dropReasonMaxAttemptsExhausted, dropReasonLRUEvicted, dropReasonLRUBacklogExhausted) and exposed stitchBacklog / maxStitchBacklog in GetQueueStats.
  • Documentation: Updated docs/features/container-profile-split-on-413.md to reflect the resolution of this known limitation.

How to Test

Run the full package validation and race detection test suite:

go build ./...
go vet ./pkg/containerprofilemanager/...
go test ./pkg/containerprofilemanager/... -race

Covered by existing rewritten tests (TestQueueLRUEviction, TestQueueDropsProfileAfterMaxAttempts) and new unit tests (TestEnforceMaxSize_EvictionEnqueuesStitch, TestEnforceMaxSize_BacklogBoundsRepairCost).

Examples/Screenshots

N/A (backend queue and timestamp continuity repair)

Related issues/PRs:

Checklist before requesting a review

  • My code follows the style guidelines of this project
  • I have commented on my code, particularly in hard-to-understand areas
  • I have performed a self-review of my code
  • If it is a core feature, I have added thorough tests.
  • New and existing unit tests pass locally with my changes

Please open the PR against the dev branch (Unless the PR contains only documentation changes)

Summary by CodeRabbit

  • Bug Fixes

    • Improved report-chain recovery when profiles are dropped after retry exhaustion or queue eviction.
    • Added bounded repair handling so replacement data cannot bypass queue capacity limits.
    • Added clear drop handling when the repair backlog is exhausted.
    • Clarified that chunks rejected with HTTP 413 are dropped without re-stitching.
  • Documentation

    • Documented drop reasons, repair behavior, backlog limits, restart behavior, and known limitations.

…xAttempts exhaustion (kubescape#871)

enforceMaxSize and the MaxAttempts drop path discarded a queued chunk with
no replacement, forking the container's report-timestamp chain and hanging
the profile in Learning forever with no field-visible symptom. Both paths
now go through the same stitch-repair mechanism kubescape#866 introduced for its
own split-chunk drops: MaxAttempts exhaustion routes through dropChunk, and
enforceMaxSize replaces an evicted item with a stitch instead of discarding
it.

Since replacing every eviction with a same-count stitch makes no net
progress toward MaxQueueSize by itself, enforceMaxSize bounds the in-flight
stitch backlog (10% of MaxQueueSize, floor 1); once exhausted, further
evictions fall back to the original unrepaired drop rather than let the
queue grow without bound or convert itself entirely into stitches in one
call.

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The queue repairs report chains after retry exhaustion and LRU eviction by creating bounded stitch replacements. It tracks stitch backlog usage, records dedicated drop reasons, exposes backlog statistics, and adds tests and documentation for repair behavior and fallback limits.

Changes

Container profile queue repair

Layer / File(s) Summary
Stitch backlog and construction
pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
The queue derives and tracks a bounded stitch backlog, centralizes stitch creation, updates accounting across queue operations, and exposes stitch statistics.
Drop and eviction repair
pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
Retry exhaustion and LRU eviction use chain-preserving stitch handling. The queue records dedicated drop reasons when repair capacity is unavailable.
Repair behavior validation and documentation
pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go, pkg/containerprofilemanager/v1/queue/containerprofile_queue_test.go, docs/features/container-profile-split-on-413.md
Tests verify retry repair, eviction repair, stitch-backlog bounds, queue-size enforcement, ordering, and report-chain timestamps. Documentation describes drop reasons and repair limitations.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Queue
  participant enforceMaxSize
  participant newStitchFor
  participant dropChunk
  Queue->>enforceMaxSize: evict queued chunk
  enforceMaxSize->>newStitchFor: create replacement stitch
  enforceMaxSize->>Queue: enqueue replacement when backlog permits
  Queue->>dropChunk: route retry-exhausted or unrepairable chunk
Loading

Possibly related issues

  • kubescape/storage#352 — The queue now preserves report-chain intervals with stitches, which relates to storage consolidation of rows with identical report intervals.

Suggested reviewers: rotemamsa

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: repairing report timestamp chains after LRU eviction and retry exhaustion.
Linked Issues check ✅ Passed The changes address issue #871 by repairing both drop paths with bounded stitch handling and adding tests for the required scenarios.
Out of Scope Changes check ✅ Passed The implementation, tests, metrics, and documentation changes directly support the linked issue and stated repair objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/containerprofilemanager/v1/queue/containerprofile_queue.go (1)

665-669: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Enforce maxStitchBacklog for all replacement stitches.

dropChunk dequeues its source item before this enqueue. If resident stitches already equal maxStitchBacklog, the queue is below maxQueueSize, so enqueueLocked does not call enforceMaxSize. This path then increments stitchBacklog above its configured bound. The lost-split path has the same bypass.

  • pkg/containerprofilemanager/v1/queue/containerprofile_queue.go#L665-L669: check backlog capacity before enqueuing the replacement stitch. If full, record a suitable fallback drop reason and leave the chain forked.
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue.go#L606-L607: use the same guarded stitch-admission path.
  • Add a test that exhausts retries while the resident stitch backlog is already full.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/containerprofilemanager/v1/queue/containerprofile_queue.go` around lines
665 - 669, Enforce maxStitchBacklog before admitting replacement stitches:
update the dropChunk path around qd.newStitchFor(dropped) and the lost-split
path around the sibling site at
pkg/containerprofilemanager/v1/queue/containerprofile_queue.go:606-607 to use
the same guarded stitch-admission logic. When backlog is full, record the
appropriate fallback drop reason, leave the chain forked, and do not enqueue or
increment stitchBacklog; add a test covering exhausted retries with the resident
stitch backlog already full.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/features/container-profile-split-on-413.md`:
- Line 129: Update the documentation around dropReasonStitchRejected to state
that stitch rejection is not the only drop path leaving the report chain forked;
qualify the LRU eviction repair statement to explicitly exempt
dropReasonLRUBacklogExhausted, while preserving the existing MaxAttempts repair
behavior.

---

Outside diff comments:
In `@pkg/containerprofilemanager/v1/queue/containerprofile_queue.go`:
- Around line 665-669: Enforce maxStitchBacklog before admitting replacement
stitches: update the dropChunk path around qd.newStitchFor(dropped) and the
lost-split path around the sibling site at
pkg/containerprofilemanager/v1/queue/containerprofile_queue.go:606-607 to use
the same guarded stitch-admission logic. When backlog is full, record the
appropriate fallback drop reason, leave the chain forked, and do not enqueue or
increment stitchBacklog; add a test covering exhausted retries with the resident
stitch backlog already full.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b6607dd0-aee4-4d61-9aac-673c909dd9e0

📥 Commits

Reviewing files that changed from the base of the PR and between 9393b35 and f89135c.

📒 Files selected for processing (4)
  • docs/features/container-profile-split-on-413.md
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_test.go

Comment thread docs/features/container-profile-split-on-413.md Outdated
…eSplit too

enforceMaxSize checked the in-flight stitch backlog before repairing an
eviction, but dropChunk and requeueSplit's lost-first-half case built and
enqueued their own replacement stitch unconditionally. Left unfixed, a
sustained run of MaxAttempts exhaustions or unsplittable drops could grow
the backlog past the bound the eviction loop itself relies on to terminate
promptly, since only enforceMaxSize's own path was gated.

Factor the check into stitchBacklogFull and call it from all three
stitch-admission sites; once the backlog is spent, dropChunk and
requeueSplit now fall back to the same unrepaired, forked drop
(dropReasonStitchBacklogExhausted) enforceMaxSize already used.

Also tightens the docs page's claim that dropReasonStitchRejected is the
only drop path that can leave the chain forked - dropReasonLRUBacklogExhausted
already qualified that, and now dropReasonStitchBacklogExhausted does too.

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>
@matthyx matthyx moved this to Needs Reviewer in KS PRs tracking Aug 6, 2026

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Verified locally on ad06ebf (fresh clone of the PR head):

go build ./...                                  ok
go vet ./pkg/containerprofilemanager/...        ok
go test ./pkg/containerprofilemanager/... -race ok (4.99s)

The direction is right and the newStitchFor extraction is a faithful refactor — the struct literals it replaces were byte-for-byte identical at all three sites. The docs update is thorough and CodeRabbit's one actionable comment is addressed.

I'm not approving yet: two of the findings below are defects against invariants this PR itself states, and both have small, mechanical fixes. The third is a design question that decides whether the MaxAttempts half of this fix actually does anything in the scenario it targets.


🔴 Blocker 1 — stitchBacklog drifts negative across a restart, permanently loosening the bound

Reproduced (probe test, since removed):

run1: size=10 stitchBacklog=1                  # one eviction repaired with a stitch
--- restart (same queue dir) ---
run2 after processAllItems: stitchBacklog=-1   # bound=1
CONFIRMED: effective budget is now 2 instead of 1

The counter starts at 0 every process start, but the disk queue can already hold stitches from the previous run. Every one of those decrements the counter as it leaves (:387 in enforceMaxSize, :489 in processAllItems), driving it negative — and nothing ever pulls it back except enqueueing an equal number of fresh stitches. If the persisted stitch succeeds on its first try, the deficit is permanent for the life of the process.

The field comment says the worst case is "a brief burst of extra replacements right after a restart before it settles." It doesn't settle on its own, and the burst isn't bounded by anything small: the drift is bounded by the number of stitches resident at startup, i.e. up to maxQueueSize. At the defaults (MaxQueueSize=1000, maxStitchBacklog=100), a process that starts after a heavy-overload run can end up with an effective budget near 1100 — which is precisely the pathology enforceMaxSize's own doc comment says the bound exists to prevent: "a single call here walk[ing] through and convert[ing] the whole queue before ever making room for the new item that triggered it" — 1000 disk enqueues in one enforceMaxSize call, all under qd.mu.

Cheapest fix is to make the release monotonic at the floor:

// releaseStitch decrements the resident-stitch count without letting it go below zero:
// the counter starts at zero each process start while the disk queue may already hold
// stitches from a prior run, and an unclamped decrement would leave a permanent deficit
// that silently widens maxStitchBacklog.
func (qd *QueueData) releaseStitch() {
	for {
		cur := qd.stitchBacklog.Load()
		if cur <= 0 {
			return
		}
		if qd.stitchBacklog.CompareAndSwap(cur, cur-1) {
			return
		}
	}
}

...called from both decrement sites, plus a test that reopens a queue over a persisted stitch and asserts stitchBacklog >= 0. (Reconciling the real count at startup would be more accurate, but you already note dque can't count a predicate cheaply — clamping is enough to keep the bound honest.)

🔴 Blocker 2 — chunksDropped counts one chunk twice on the backlog-exhausted and enqueue-failed paths

Reproduced: a single dropChunk call with the backlog at its bound yields

chunksDropped=2, reasons=[max-attempts-exhausted stitch-backlog-exhausted]

dropChunk increments at :686 for the drop itself, then again at :699 when the repair is refused. Same shape at :620+:629 (requeueSplit) and :407+:418 (enforceMaxSize's failed enqueue). Meanwhile enforceMaxSize's own backlog-full path at :396-404 increments exactly once for the same logical event.

So chunksDropped is not a count of dropped chunks — it over-reports by up to 2× exactly when things are going worst, and it's internally inconsistent between two paths that describe the same outcome. The drop-reason counter has the same problem: sum(chunk_dropped_total) by (reason) no longer equals chunks dropped, so any dashboard or alert built on it silently double-counts overload.

Two coherent options — either is fine, but please pick one and apply it at all four sites:

  1. One increment per chunk. Keep the double ReportContainerProfileChunkDropped (the two labels do carry distinct information: why dropped and why not repaired) but increment chunksDropped only once. Then document on the metric that a chunk emits one or two reason samples, so nobody sums them as a chunk count.
  2. Separate the concerns. Keep chunksDropped as a pure chunk count and move "repair refused" onto its own counter/metric (stitchesRefused), so both series stay individually summable.

TestDropChunk_StitchBacklogExhaustedForksWithoutReplacement currently asserts only Contains on the reasons, so it passes under either — worth tightening it to pin the counter value once you've decided.

🟠 Discuss — the MaxAttempts repair stitch inherits an exhausted budget, so it gets exactly one try

newStitchFor copies Attempts: dropped.Attempts. Coming from dropReasonMaxAttemptsExhausted that value is already >= maxAttempts, so the stitch is attempted once, fails, and is dropped un-restitched. Your own test pins this: assert.Equal(t, maxAttempts+1, creator.callCount(), "the original gets its full budget, the stitch exactly one try").

Now walk the motivating scenario from #871: storage is unreachable, an item burns 360 retries over ~30 minutes, dropChunk fires, the stitch is tried once ~5s later against the same unreachable storage, fails, and the chain forks anyway. For a sustained outage — the case the retry budget exists to survive — the MaxAttempts half of this fix changes nothing but the log line. It does genuinely help the other case (an item-specific persistent rejection, where a metadata-only stitch succeeds immediately), and that's worth having; I just don't think the PR description's framing holds for the outage case.

Inheriting Attempts is clearly correct for the 413 lineage — it's what bounds the split/stitch cascade. For the retry-exhaustion path the trade looks different: a fresh budget is still bounded (a stitch is never re-stitched, so total exposure is 30 min + 30 min, not unbounded), and a stitch is a couple of KB. The cost is queue slots, not bytes — which is real, but it's the same cost you already accepted by enqueueing the stitch at all.

If you keep the current behaviour, could you say so explicitly in the dropReasonMaxAttemptsExhausted comment and in the docs' "Known limitations" — something to the effect that the repair is best-effort and does not survive the outage that caused the drop? Right now the docs read as though this path is fixed outright.

🟡 Minor

  1. enqueueStitchNoEvict's lock-discipline claim is wrong (:313): "enforceMaxSize always runs under qd.mu (it's only ever called from enqueueLocked, whose callers must already hold the lock)". NewQueueData:250 calls enforceMaxSize() directly, without the lock. It's safe there — no other goroutine exists yet — but this is a concurrency-critical file and the comment asserts an invariant the code doesn't hold. Please either note the constructor exception or move the startup sweep under the lock.

  2. A restart at capacity now sheds two real chunks instead of one. Confirmed in the same probe: reopening a full queue took it 10 → 9, evicting one real chunk into a stitch (no net progress) and then forking a second. That startup sweep runs with no new item waiting for room, so the second eviction buys nothing. Pre-fix this cost one delta per restart; it now costs two, every restart, on every node. Worth a line in the docs at minimum — or skipping the repair entirely when enforceMaxSize is called outside an enqueue.

  3. GetQueueStats can report a negative stitchBacklog — falls out of Blocker 1, and it'll look like a bug to whoever reads the stats endpoint. Fixed by the clamp.

  4. stitchBacklogFull() is a soft bound in dropChunk (:693 checks outside qd.mu, :706 enqueues inside). A dropChunk on the processor goroutine can race an enforceMaxSize under the lock and overshoot by one. Harmless in practice, but the doc comment reads as a hard invariant — a one-line "advisory, may overshoot by one under concurrent admission" would set expectations correctly.

👍 Confirmed correct

  • enforceMaxSize terminates: every iteration either shrinks the queue or spends one unit of a bounded budget, so the loop is bounded at maxStitchBacklog + 1 iterations — modulo Blocker 1 inflating that bound.
  • I traced TestQueueLRUEviction's rewritten expectations by hand against enqueueStitchNoEvict appending at the tail; [stitch(0), profile-3, profile-4] with chunksDropped == 3 is right.
  • The new drop reasons are compile-time constants from a closed set, so Prometheus/OTEL label cardinality stays bounded.
  • EmptyQueue's stitchBacklog.Store(0) correctly resets the counter alongside the drain.

Happy to re-review as soon as 1 and 2 are in — they're both small, and I'd merge on your answer to the third.

Comment thread pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
Comment thread pkg/containerprofilemanager/v1/queue/containerprofile_queue.go Outdated
Comment thread pkg/containerprofilemanager/v1/queue/containerprofile_queue.go Outdated
Comment thread pkg/containerprofilemanager/v1/queue/containerprofile_queue.go Outdated
@matthyx matthyx moved this from Needs Reviewer to Waiting on Author in KS PRs tracking Aug 6, 2026
…, fresh retry budget for MaxAttempts stitches

Addresses review findings on the queue-drop-lineage fix:

- stitchBacklog started at zero every process start but the disk queue can
  already hold stitches from a prior run; decrementing for one of those (which
  never incremented the fresh counter) drove it negative permanently, silently
  widening maxStitchBacklog by up to maxQueueSize. releaseStitch clamps the
  decrement at zero.

- chunksDropped was incremented twice for a single lost chunk whenever its
  repair also failed (dropChunk, requeueSplit's lost-first-half case, and
  enforceMaxSize's failed stitch enqueue all double-counted). Each site now
  increments the counter once per chunk and reports both drop reasons as
  separate metric samples instead.

- A stitch repairing a MaxAttempts-exhaustion drop inherited the parent's
  already-exhausted Attempts, so it got exactly one try - a no-op against a
  storage outage that hasn't recovered. dropChunk now gives that stitch a
  fresh retry budget (newStitchFor's freshAttempts); a stitch is never
  re-stitched, so total exposure stays bounded at two retry windows.

- dropChunk, requeueSplit's lost-first-half stitch, and enforceMaxSize's own
  eviction loop now all gate on stitchBacklogFull before admitting a stitch,
  not just enforceMaxSize's loop.

- NewQueueData's startup enforceMaxSize sweep now runs under qd.mu, matching
  the invariant enqueueStitchNoEvict's doc comment already claimed.

- Documented two residual trade-offs: the MaxAttempts repair is best-effort
  under a sustained outage, and a restart at capacity can now shed two deltas
  instead of one.

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/features/container-profile-split-on-413.md`:
- Line 129: Update the stitch-size wording in the documentation to say that
stitches are normally smaller, rather than guaranteeing they cannot exceed the
413 cap. Preserve the existing behavior and explanation of 413 rejection,
backlog eviction, and retry exhaustion.

In `@pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go`:
- Around line 595-608: The queue must restore the resident stitch count when
reopening persisted data so new admissions remain within maxStitchBacklog. In
pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go:595-608,
replace the zero-baseline assertions with coverage that reopens persisted
stitches, admits new stitches, and verifies the total remains bounded. In
docs/features/container-profile-split-on-413.md:209-213, retain the all-path
bound claim only if startup restoration is implemented; otherwise document the
restart limitation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d22c794c-f8b6-46ce-86c5-ffed307cc76a

📥 Commits

Reviewing files that changed from the base of the PR and between f89135c and 39bcdf8.

📒 Files selected for processing (3)
  • docs/features/container-profile-split-on-413.md
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue.go

Comment thread docs/features/container-profile-split-on-413.md

@matthyx matthyx left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 39bcdf8 — all findings addressed, approving ✅

Re-ran everything on the new head:

go build ./...                                  ok
go vet ./pkg/containerprofilemanager/...        ok
go test ./pkg/containerprofilemanager/... -race ok (4.96s)

I re-ran my two original repro probes unchanged against the new code rather than reading the commit message, and added two more to check the fixes didn't just move the problem.

Blocker 1 — stitchBacklog negative drift → fixed

Same probe as before, same restart-over-a-persisted-stitch setup:

before (ad06ebf):  stitchBacklog=-1  bound=1   → effective budget 2
after  (39bcdf8):  stitchBacklog=0   bound=1   → full=false, budget intact

releaseStitch's CAS loop is correct — the cur <= 0 early return means a lost race retries against a fresh load, and the loop can't spin indefinitely since every competing operation makes progress.

I also checked the direction of the residual error, since clamping trades one inaccuracy for another. Interleaving untracked (prior-run) releases with tracked enqueues, the counter never exceeded the true resident count — so it can under-count and permit an extra repair, but it can never over-count and refuse one. That matches what the new field comment claims, and it's the safe direction: the failure mode is a slightly generous budget for a bounded window, not a silently unrepaired chain.

Blocker 2 — chunksDropped double-count → fixed

before (ad06ebf):  one dropChunk → chunksDropped=2
after  (39bcdf8):  one dropChunk → chunksDropped=1, reasons=[max-attempts-exhausted stitch-backlog-exhausted]

Option 1, which is the one I'd have picked: one increment per lost chunk, both reason labels retained since they answer different questions. I audited all five increment sites in the final file — :455, :465, :682, :716, :746 — and every follow-up "repair also failed" branch (:478, :691, :703, :762, :781) now reports a reason without touching the counter. The rule holds uniformly, including on the path that was already correct.

Tightening the assertions from Contains to exact-slice equality in TestDropChunk_StitchBacklogExhaustedForksWithoutReplacement was the right call — that test would otherwise have passed under either scheme.

Discussion item — fresh retry budget → adopted, with a consequence worth naming

newStitchFor(dropped, freshAttempts) with dropChunk passing reason == dropReasonMaxAttemptsExhausted is exactly the narrow application I was hoping for: the 413/split lineage keeps inheriting Attempts (which is what bounds the split/stitch cascade), and only the retry-exhaustion repair gets a fresh window. Since a stitch is never re-stitched, total exposure is two retry windows — I confirmed that bound holds by draining a queue against a permanently-failing creator and seeing exactly N+1 drops for N originals, never N+2.

One behavioural consequence to be aware of, not a blocker: because a fresh-budget stitch now occupies a backlog slot for a full retry window (~30 min at defaults) instead of ~5s, maxStitchBacklog has become a cap on how many chains can be repaired concurrently during a sustained outage — roughly 100 at defaults. Probed with maxStitchBacklog=1 and 5 dropping containers: exactly one got a repair stitch, the other four reported stitch-backlog-exhausted.

I think that's the right trade — the repairs it displaces were the one-shot kind that couldn't have succeeded anyway — and it's bounded at 10% of the queue and directly observable through the drop-reason metric. Just worth knowing that during a long outage the stitch-backlog-exhausted counter is expected to be non-zero and isn't by itself a sign of a bug.

Minors — all four addressed

  • NewQueueData now takes qd.mu around the startup sweep, so enqueueStitchNoEvict's invariant is true as written rather than documented-around. Good call taking the lock instead of carving out an exception.
  • The two-deltas-per-restart-at-capacity cost is now documented in Known limitations, and the doc correctly notes that exactly-MaxQueueSize already triggered an eviction pre-change.
  • Negative stitchBacklog can no longer surface in GetQueueStats; the new test asserts it via the stats map, which is the right place to pin it.
  • stitchBacklogFull's advisory nature is now stated on the function.

Also checked, no issues

  • No deadlock from the new NewQueueData locking — enforceMaxSize reaches only enqueueStitchNoEvict, which doesn't take qd.mu.
  • A stitch that exhausts its own fresh budget still hits the dropped.IsStitch early return in dropChunk, so it's dropped un-restitched; no loop.
  • Queue-occupancy cost of longer-lived stitches stays capped at 10% of MaxQueueSize by the existing bound.
  • requeueSplit's second-half-failure path was already single-counting and was correctly left alone.

Nice work — the fixes are minimal, the reasoning is in the comments rather than the commit message, and each one landed with a test that would actually catch the regression. LGTM.

@matthyx
matthyx merged commit b33eb18 into kubescape:main Aug 7, 2026
8 of 9 checks passed
@aryanghai12
aryanghai12 deleted the fix/containerprofile-queue-drop-lineage branch August 7, 2026 08:53
@matthyx matthyx moved this from Waiting on Author to To Archive in KS PRs tracking Aug 7, 2026
entlein pushed a commit to k8sstormcenter/node-agent that referenced this pull request Aug 26, 2026
…and retry exhaustion (kubescape#884)

* fix(containerprofile): repair the report chain on LRU eviction and MaxAttempts exhaustion (kubescape#871)

enforceMaxSize and the MaxAttempts drop path discarded a queued chunk with
no replacement, forking the container's report-timestamp chain and hanging
the profile in Learning forever with no field-visible symptom. Both paths
now go through the same stitch-repair mechanism kubescape#866 introduced for its
own split-chunk drops: MaxAttempts exhaustion routes through dropChunk, and
enforceMaxSize replaces an evicted item with a stitch instead of discarding
it.

Since replacing every eviction with a same-count stitch makes no net
progress toward MaxQueueSize by itself, enforceMaxSize bounds the in-flight
stitch backlog (10% of MaxQueueSize, floor 1); once exhausted, further
evictions fall back to the original unrepaired drop rather than let the
queue grow without bound or convert itself entirely into stitches in one
call.

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>

* fix(containerprofile): bound stitch admission in dropChunk and requeueSplit too

enforceMaxSize checked the in-flight stitch backlog before repairing an
eviction, but dropChunk and requeueSplit's lost-first-half case built and
enqueued their own replacement stitch unconditionally. Left unfixed, a
sustained run of MaxAttempts exhaustions or unsplittable drops could grow
the backlog past the bound the eviction loop itself relies on to terminate
promptly, since only enforceMaxSize's own path was gated.

Factor the check into stitchBacklogFull and call it from all three
stitch-admission sites; once the backlog is spent, dropChunk and
requeueSplit now fall back to the same unrepaired, forked drop
(dropReasonStitchBacklogExhausted) enforceMaxSize already used.

Also tightens the docs page's claim that dropReasonStitchRejected is the
only drop path that can leave the chain forked - dropReasonLRUBacklogExhausted
already qualified that, and now dropReasonStitchBacklogExhausted does too.

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>

* fix(containerprofile): clamp stitch backlog, fix double-counted drops, fresh retry budget for MaxAttempts stitches

Addresses review findings on the queue-drop-lineage fix:

- stitchBacklog started at zero every process start but the disk queue can
  already hold stitches from a prior run; decrementing for one of those (which
  never incremented the fresh counter) drove it negative permanently, silently
  widening maxStitchBacklog by up to maxQueueSize. releaseStitch clamps the
  decrement at zero.

- chunksDropped was incremented twice for a single lost chunk whenever its
  repair also failed (dropChunk, requeueSplit's lost-first-half case, and
  enforceMaxSize's failed stitch enqueue all double-counted). Each site now
  increments the counter once per chunk and reports both drop reasons as
  separate metric samples instead.

- A stitch repairing a MaxAttempts-exhaustion drop inherited the parent's
  already-exhausted Attempts, so it got exactly one try - a no-op against a
  storage outage that hasn't recovered. dropChunk now gives that stitch a
  fresh retry budget (newStitchFor's freshAttempts); a stitch is never
  re-stitched, so total exposure stays bounded at two retry windows.

- dropChunk, requeueSplit's lost-first-half stitch, and enforceMaxSize's own
  eviction loop now all gate on stitchBacklogFull before admitting a stitch,
  not just enforceMaxSize's loop.

- NewQueueData's startup enforceMaxSize sweep now runs under qd.mu, matching
  the invariant enqueueStitchNoEvict's doc comment already claimed.

- Documented two residual trade-offs: the MaxAttempts repair is best-effort
  under a sustained outage, and a restart at capacity can now shed two deltas
  instead of one.

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>

---------

Signed-off-by: aryanghai12 <aryanghai1205@gmail.com>
Signed-off-by: entlein <einentlein@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

A MaxAttempts exhaustion drop or LRU eviction can permanently break a container's report chain, pre-existing on main

2 participants