Skip to content

fix(containerprofile): split oversized chunks on HTTP 413 instead of ending learning - #866

Merged
matthyx merged 5 commits into
mainfrom
fix/container-profile-queue-413
Jul 31, 2026
Merged

fix(containerprofile): split oversized chunks on HTTP 413 instead of ending learning#866
matthyx merged 5 commits into
mainfrom
fix/container-profile-queue-413

Conversation

@rotemamsa

@rotemamsa rotemamsa commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #865

Problem

customer of Rancher/RKE2 saw storage log this on repeat for cattle-system workloads:

QueueManager - request entity too large  contentLength=3231797  maxObjectSize=2500000

storage's QueueManager rejects a create whose Content-Length exceeds kindQueues.containerprofiles.maxObjectSize before it reaches the registry, replying 413 with a plain-text body — so there is no Status object and no sentinel.

What changed since the last revision of this PR

@matthyx's review (#866 (review)) verified the original fix's diagnosis and bounded-retry mechanism, but flagged one blocking concern: mapping a bare 413 onto ObjectTooLargeError conflates two different signals. ObjectTooLargeError is storage's own aggregate findings-count check — an authoritative "this container's profile is genuinely full" verdict that's fair to end learning on. A bare 413 is a transport rejection of one delta based on byte size alone, and treating it the same way throws away all future learning for a container that merely produced one oversized chunk.

This revision implements the reviewer's suggested fix: on a bare 413, split the rejected chunk in half and resend both halves, recursing until each piece is accepted, instead of ending learning.

Change

classifyFailure() gains a third reaction, failureSplit, alongside the existing failureRetryable/failureTerminal. The two genuine storage sentinels (ObjectTooLargeError, ObjectCompletedError, matched via errors.Is + substring) are checked first and remain terminal exactly as before — only a bare 413 detected purely by HTTP status code (apierrors.IsRequestEntityTooLargeError) now triggers failureSplit instead of being treated as terminal.

Splitting (containerprofile_split.go, new). splitProfile partitions the rejected chunk's list/map fields (Capabilities, Execs, Opens, Syscalls, Endpoints, IdentifiedCallStacks, Ingress, Egress, PolicyByRuleId) roughly in half, with a non-empty guarantee so recursion always makes progress, and gives each half a fresh one-time slug over the same base name (recovered via storage's own file.SplitProfileName, so agreement with the server's aggregation key is true by construction — a -partN suffix scheme was considered and rejected, since SplitProfileName cuts on the last hyphen and would scatter a differently-suffixed half into a phantom aggregate). A byte-progress guard refuses to keep splitting a chunk that isn't shrinking.

Preserving the report chain — the trickiest part. Storage reconstructs a container's lifecycle by walking a strictly linear previousReportTimestamp → reportTimestamp chain (consolidateContinuousTimeSeries). Naively giving both halves the parent's identical timestamp pair permanently forks that chain and leaves the profile in Learning forever — worse than today's behavior. Instead, chainHalves interposes a fresh timestamp X so one half spans (P, X] and the other (X, T], keeping the chain linear (including for the next real report, and correctly under recursive re-splitting). This required care around: a monotonic-clock suffix on production timestamps (time.Now().String()) that needs stripping before parsing; a numeric (non-letter) timezone-abbreviation fallback for several IANA zones; and never taking a naive midpoint against a first-report's zero previousReportTimestamp (which would otherwise produce an implausible timestamp that storage's expiry check matches unconditionally, re-ending learning through a different route).

Bounding the recursion. MaxSplitDepth (default 4) plus the byte-progress guard cap how many times a single chunk can be halved. A chunk that can't be split further (a single oversized element, or depth exhausted) is dropped with a Warning — and replaced by a stitch chunk: a metadata-and-scalars-only stand-in that preserves the dropped chunk's (previousReportTimestamp, reportTimestamp) link so the chain doesn't fork, without carrying any of the oversized data. A stitch is never itself re-stitched. Neither drop path calls ErrorCallback.OnQueueError — that's the callback that ends learning, and avoiding it on every 413 path is the entire point of this change.

Observability. Two new counters (ReportContainerProfileSplit, ReportContainerProfileChunkDropped(reason)) across all MetricsManager implementations, since after this change there's no remaining status signal on the 413 path at all — these counters are the only way to see the underlying estimator issue (#870) firing in the field.

The bounded-retry mechanism from the previous revision (MaxAttempts, default 360) is unchanged and still governs genuinely retryable failures.

Tests

containerprofile_split_test.go (new) and containerprofile_queue_errors_test.go (extended) — partition correctness (no loss/duplication across halves), shared-field verbatim-copy vs. the two report-timestamp fields (deliberately not equal across halves), naming/base-name preservation, the floor case and non-empty rebalance, determinism, the timestamp-chain interposition and its zero-time/monotonic-suffix/numeric-timezone edge cases (including a golden port of storage's own consolidation algorithm so drift against the real thing is caught), recursive re-splitting staying chain-linear, the byte-progress guard, and end-to-end queue tests for split-and-resend, the unsplittable floor case (with stitch-chunk emission), MaxSplitDepth bounding, and SplitDepth/stitch persistence across a queue restart.

Verified:

go build ./...                                                                          ok
go vet ./pkg/containerprofilemanager/... ./pkg/metricsmanager/...                        ok
go test ./pkg/containerprofilemanager/... ./pkg/metricsmanager/... -race -count=3        ok
gofmt -l (all changed files)                                                             clean

Also fixed a pre-existing data race in the test-only MockProfileCreator (unsynchronized fields read across goroutines), found while getting the package's -race gate green.

Not in this PR

Summary by CodeRabbit

  • New Features

    • Container-profile learning now splits profiles rejected for excessive size and retries manageable chunks.
    • Added safeguards for retry limits, split-depth limits, unsplittable chunks, and queue shutdown.
    • Added stitch chunks to preserve continuity when portions are dropped.
    • Added monitoring metrics for profile splits and dropped chunks.
  • Documentation

    • Added guidance on size rejection handling, retries, timestamp processing, observability, and limitations.
  • Tests

    • Added comprehensive coverage for splitting, retries, persistence, stitching, queue limits, and error handling.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3933d1fe-9fad-4b1d-ab2f-d611346d1606

📥 Commits

Reviewing files that changed from the base of the PR and between 49f5f19 and 316ed2f.

📒 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.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/features/container-profile-split-on-413.md
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors.go

📝 Walkthrough

Walkthrough

The queue now detects HTTP 413 responses, splits oversized container profiles, preserves report-chain metadata with stitch chunks, limits retries and split depth, persists processing state, and reports split/drop metrics through Prometheus and OTEL implementations.

Changes

Container profile 413 handling

Layer / File(s) Summary
Profile splitting and timestamp stitching
pkg/containerprofilemanager/v1/queue/containerprofile_split.go, pkg/containerprofilemanager/v1/queue/containerprofile_split_test.go
Profiles are partitioned deterministically across collections and policy maps. Split names are regenerated, timestamp chains remain valid, and dropped chunks can be replaced with metadata-only stitch profiles.
Queue failure handling and bounded processing
pkg/containerprofilemanager/v1/queue/containerprofile_queue.go, pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors.go, pkg/containerprofilemanager/v1/queue/*_test.go
HTTP 413 errors are classified separately from terminal and retryable errors. Profiles are split or dropped within configured limits. Retry, split-depth, and stitch state persist across requeue and restart operations.
Metrics and supporting documentation
pkg/metricsmanager/..., docs/features/container-profile-split-on-413.md
Prometheus and OTEL metrics report profile splits and dropped chunks with rejection reasons. The feature documentation describes timestamp handling, bounds, stitching, metrics, tests, and limitations.
Concurrent test-double access
pkg/containerprofilemanager/v1/queue/containerprofile_queue_test.go
MockProfileCreator protects call tracking and created profiles with a mutex and exposes synchronized accessors.

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

Sequence Diagram(s)

sequenceDiagram
  participant Queue
  participant ProfileCreator
  participant classifyFailure
  participant Splitter
  participant MetricsManager
  Queue->>ProfileCreator: create container profile
  ProfileCreator-->>Queue: return HTTP 413
  Queue->>classifyFailure: classify response
  classifyFailure-->>Queue: return split category
  Queue->>Splitter: split profile or create stitch chunk
  Splitter-->>Queue: return bounded queue items
  Queue->>MetricsManager: report split or dropped chunk
  Queue->>ProfileCreator: process requeued items
Loading

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR prevents infinite retries and preserves sentinel handling, but it does not implement issue #865's specified 413-to-too-large behavior or default retry limit of 10. Align 413 handling and retry defaults with issue #865, or update the issue criteria to document split-and-resend with a 360-attempt limit.
Out of Scope Changes check ⚠️ Warning The queue and container-profile metrics changes are in scope, but adding unrelated projection metric methods to MetricsManager is outside issue #865. Remove the unrelated projection metric additions or link them to a separate issue.
Docstring Coverage ⚠️ Warning Docstring coverage is 61.90% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: splitting oversized container-profile chunks after HTTP 413 responses.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/container-profile-queue-413

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.

rotemamsa and others added 2 commits July 27, 2026 12:44
…orever

Storage's QueueManager rejects a create whose Content-Length exceeds
kindQueues.containerprofiles.maxObjectSize with an HTTP 413 and a plain-text
body. The queue classified failures by exact string equality against
file.ObjectTooLargeError and file.ObjectCompletedError, and a 413 matches
neither, so it fell through to requeueImmediate.

The consequences compound: the profile was retried every RetryInterval (5s)
forever, the dque is disk-persistent so the poison item survived pod restarts,
and because requeuing appends to the tail while enforceMaxSize evicts from the
head, it could starve newer profiles that would have been accepted. The
container was never flagged too-large, because that only happens via
OnQueueError, so the profile silently had a hole in it with no UI signal.

Replace the string comparison with classifyFailure():

  - HTTP 413 is detected with apierrors.IsRequestEntityTooLargeError, which
    matches on the status code and so works despite the plain-text body, and is
    reported as ObjectTooLargeError. handleSaveProfileError then sets
    status=too-large and ends learning with reason "too_large".
  - Sentinels are matched with errors.Is plus a substring check on the message,
    because a StatusError only carries the message and storage prefixes the
    sentinel with the limit on some paths. Exact equality missed both shapes.

Also bound retries with MaxAttempts (default 10). A 413 is only one way to get
a permanently failing item; without a bound, any such item occupies the queue
indefinitely. Attempts is added to the persisted item, and items written before
this field existed decode with it at zero, so they get a full retry budget.

Reported by ISO Gruppe on Rancher/RKE2: contentLength=3231797 against
maxObjectSize=2500000 for cattle-system workloads.

Signed-off-by: Rotem Refael <rotem@armosec.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…start

DefaultMaxAttempts of 10 at a 5s interval gave only 50 seconds of tolerance.
storage is a single-replica apiserver backed by a PVC, so a rollout, image pull
or node eviction routinely takes longer than that, and profiles would have been
dropped during an ordinary restart that the previous unbounded retry survived.

Raise the default to 360, roughly 30 minutes, which still sheds a permanently
failing item but no longer trades the infinite-retry bug for silent data loss.
Add a test asserting the budget stays above 15 minutes so it cannot be
tightened back without the tradeoff being considered.

Signed-off-by: Rotem Refael <rotem@armosec.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rotemamsa
rotemamsa force-pushed the fix/container-profile-queue-413 branch from b02ad07 to a8c9bb6 Compare July 27, 2026 09:45
@matthyx matthyx moved this to WIP in KS PRs tracking Jul 27, 2026
@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.189 0.188 -0.9%
Peak CPU (cores) 0.199 0.196 -1.7%
Avg Memory (MiB) 336.578 267.488 -20.5%
Peak Memory (MiB) 340.598 273.270 -19.8%
Dedup Effectiveness

No data available.

@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.

Thanks for the thorough writeup — the diagnosis in #865 is right, and I verified every load-bearing claim against kubescape/storage@v0.0.290: QueueManager really does reject on Content-Length with a plain-text 413, CreateContainerProfileDirect passes the clientset error through unwrapped, and the exact-string classification on main falls through to requeueImmediate forever on a disk-persistent queue. The bounded retry and the errors.Is + relayed/prefixed StatusError matching are real, independent fixes and I'd like to keep both.

My one blocking concern is the 413 -> ObjectTooLargeError -> end learning hop. I don't think we should stop learning just because a chunk is too big — we're sending TS container profiles here, and the two conditions are genuinely different:

  • ObjectTooLargeError is a findings-count check on the aggregated profile. In ContainerProfileProcessor.PreSave, the TS branch (ReportSeriesIdMetadataKey != "") returns early and never size-checks the chunk at all — it only rejects when the existing aggregate is already flagged too-large/completed. The size rule lives in the non-TS branch and counts findings, not bytes:

    size += len(profile.Spec.Execs) + len(profile.Spec.Opens) + len(profile.Spec.Syscalls) +
            len(profile.Spec.Capabilities) + len(profile.Spec.Endpoints) +
            len(profile.Spec.IdentifiedCallStacks) + len(profile.Spec.Ingress) + len(profile.Spec.Egress)
    if size > a.MaxContainerProfileSize {
        // set annotation but don't return an error as we want to save the profile anyway
        profile.Annotations[helpers.StatusMetadataKey] = helpers.TooLarge
    }

    It doesn't even return an error — it annotates and saves. So ObjectTooLargeError on a chunk means "the aggregate is full, stop sending", which is a fair reason to end learning.

  • The 413 is a defensive Content-Length check on one delta. Mapping it onto the sentinel manufactures the "container is full" verdict out of a transport limit.

What I'd like instead: split the chunk and send both halves

Since saveContainerProfile calls emptyEvents() after each enqueue, every queued profile is a delta. Cutting one delta in two is exactly what the existing ProfileRequiresSplit path already does (container_operations.go:38-50 — it's literally "flush early and start a new chunk"), just reactive instead of predictive. I checked the three things that could have broken it:

  1. Naming works, with one trap. GetOneTimeSlug returns <base-slug>-<uuid-hex>, and storage's SplitProfileName cuts on the last hyphen to recover the base name. Each half needs its own fresh -<uuid-hex> on the base name. Do not suffix the existing chunk name (...-<uuid>-part1) — SplitProfileName would then return base-<uuid> and scatter the halves under a different aggregate key.
  2. The halves can't collide. time_series has PRIMARY KEY (kind, namespace, name, seriesID, tsSuffix) and AfterCreate writes tsSuffix from the per-chunk UUID, so two halves land as distinct rows even with identical reportTimestamp / previousReportTimestamp / seriesID. Nothing needs re-stamping.
  3. The aggregate is unchanged. Chunks merge by patchMergeKey and the count is len() after dedupe, so how the delta was cut doesn't affect the findings total.

What splits vs what duplicates: partition the lists (Capabilities, Execs, Opens, Syscalls, Endpoints, IdentifiedCallStacks, Ingress, Egress) and the PolicyByRuleId map; copy Architectures, ImageID, ImageTag, SeccompProfile, LabelSelector and the annotations into both halves.

Halving converges in ceil(log2(bytes/cap)) rounds — 3.23 MB against 2.5 MB is a single round — and costs nothing server-side, since QueueManager rejects before dispatch. Floor case: a chunk holding one element across all lists that still 413s is genuinely unsendable, so drop that chunk with a Warning; still not the container.

Two smaller things

  • The "container is finally flagged too-large" outcome doesn't actually land on the 413 path. handleSaveProfileError sets the status on the in-memory watchedContainer, then calls deleteContainer, which explicitly skips the termination save when status is TooLarge (lifecycle.go:287-290). We only write StatusMetadataKey inside saveContainerProfile (monitoring.go:181), so no chunk ever carries status=too-large to storage. For the registry sentinel path that's fine — storage annotates the object itself — but for a 413 the request never reaches the registry, so the series is just abandoned and the expiry sweep later marks it Completed/Partial. Worth correcting in the description either way.
  • The estimator is why we get a 413 at all. maxTsProfileSize defaults to 2 MiB — already under storage's 2.5 MB — yet the observed body was 3.23 MB, because size mixes byte counts (ReportCapability -> size.Of(...)) with element counts (ReportSyscall -> syscalls.Append(...), the number of elements added) and never accounts for egress/ingress neighbors being expanded at serialization time after DNS resolution. That's a separate node-agent-side defect from the cap inconsistency tracked in kubescape/storage#350 — worth its own issue. Fix it and the split stays a safety net rather than the normal path.

Verification on my side: go build ./... ok, go test ./pkg/containerprofilemanager/... all pass, gofmt/vet clean. The -race failure in TestQueueWithDifferentConfigurations/small-queue does reproduce on main, so that note is accurate. I also confirmed the gob claim independently — dque uses gob (segment.go:138,221), old->new decodes with Attempts=0 and new->old ignores the extra field, so the persisted-item change is safe both ways.

// QueueManager rejects oversized requests up front with 413 and a plain-text body, so
// there is no Status object to carry a reason. IsRequestEntityTooLargeError matches on
// the status code alone, which is what makes this work.
if apierrors.IsRequestEntityTooLargeError(err) {

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.

This is the part I'd like changed. Detecting the 413 here is right — IsRequestEntityTooLargeError matching on the status code alone is exactly what makes it work against the plain-text body. Reporting it as file.ObjectTooLargeError is what I'd push back on.

The two conditions aren't the same. ObjectTooLargeError is a findings-count verdict on the aggregated profile — and note that for a TS chunk, PreSave returns early and never size-checks the chunk at all, so the sentinel only ever reaches us when the aggregate is already flagged. The 413 is a Content-Length check on a single delta.

Suggest a third failureKind here — something like failureOversized — so the queue can split the chunk and requeue both halves instead of ending learning for the container. The sentinel path stays exactly as you have it.

// (an HTTP 413 from storage's QueueManager carries no sentinel at all).
if qd.errorCallback != nil {
qd.errorCallback.OnQueueError(queuedProfile.Profile, queuedProfile.ContainerID, err)
qd.errorCallback.OnQueueError(queuedProfile.Profile, queuedProfile.ContainerID, reported)

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.

This is where the consequence lands: reported is the sentinel, so handleSaveProfileError sets status=too-large, calls deleteContainer and ends learning with reason too_large — permanently, for the whole container, on one oversized delta.

Since saveContainerProfile calls emptyEvents() after each enqueue, every queued profile is a delta, and the first report after container start carries everything observed so far — so this fires on the peak, not on a steady state. On main the container kept learning and reached Completed at max sniff time, losing only that one delta.

For the oversized case I'd like: split the profile in two, requeue both, no OnQueueError, learning continues. The ObjectCompletedError / aggregate-ObjectTooLargeError sentinels should keep going through this branch unchanged — those genuinely mean "stop sending".

// succeeds cannot occupy the queue indefinitely: requeuing appends to the tail
// while enforceMaxSize evicts from the head, so an unbounded retry would push
// out newer profiles that could have been saved.
queuedProfile.Attempts++

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.

Once the split lands, a chunk being usefully halved shouldn't consume this budget — otherwise productive splits burn retries meant for storage being unreachable. Worth carrying a separate split-depth counter on QueuedContainerProfile alongside Attempts, with the drop happening when a chunk can't be divided any further rather than when the retry budget runs out.

The bound itself is good and I'd keep it — including the 360 default and TestDefaultRetryBudgetOutlastsARestart. One thing worth adding: this path drops silently, with no callback and no metric. With a full 1000-item queue and a >30 min outage, every item exhausts its budget in roughly the same tick, so this can emit up to 1000 Warning lines at once. A counter and rate-limited logging would make the loss visible.

// matchesSentinel reports whether err carries the given storage sentinel, either as a
// wrapped error or as text in the message relayed by the apiserver.
func matchesSentinel(err, sentinel error) bool {
return errors.Is(err, sentinel) || strings.Contains(err.Error(), sentinel.Error())

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.

Minor: the substring arm is unanchored, so any error whose message happens to embed "object is too large" or "object is completed" becomes terminal and ends learning for the container. Low probability, but the blast radius is large for a text match.

The errors.Is arm and the prefixed-message case are both well justified — storage really does wrap as "...size exceeds the limit of %d: %w". Could we narrow the substring arm to *apierrors.StatusError (where we know the message is a relayed sentinel and errors.Is can't work) rather than applying it to every error shape?

// TestQueueEndsLearningOnHTTP413 is the regression test for the ISO Gruppe report: an
// oversized profile rejected by storage's QueueManager must end learning instead of being
// retried every RetryInterval forever.
func TestQueueEndsLearningOnHTTP413(t *testing.T) {

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.

This test pins the behaviour I'm asking to change, so it'll need reshaping: a 413 should result in the profile being split and both halves delivered, with OnQueueError never called and learning still running.

The rest of the file is genuinely good — building the errors the way client-go actually does (NewGenericServerResponse for the plain-text 413, a relayed StatusError for the sentinels) is the right level of fidelity, and TestClassifyFailure_413IsNotRequeued is a nice guard against the old equality check coming back. A couple worth adding alongside the split tests:

  • the two halves carry distinct -<uuid-hex> name suffixes that SplitProfileName maps back to the same base name (a ...-part1 style suffix would silently re-base the aggregate key);
  • Attempts survives a queue Close/reopen — the whole retry bound depends on that counter being persisted, and nothing exercises it today.

…ending learning

storage's QueueManager rejects a create whose Content-Length exceeds its size
cap with a bare HTTP 413 and no sentinel. Previously this was treated the same
as storage's own too-large sentinel, permanently ending learning for the
container. A 413 is a transport limit on one delta, not an aggregate signal,
so a container that produces a single oversized chunk should not lose all
future learning.

classifyFailure now returns a third failureSplit kind for a bare 413 (the two
genuine sentinels remain terminal, and are checked first so an authoritative
signal always wins over a status code). On failureSplit, the rejected chunk is
recursively halved and both halves re-enqueued until each is small enough to
be accepted, bounded by MaxSplitDepth and a byte-progress guard.

Halving requires care to avoid corrupting storage's time-series consolidation:
giving both halves the same report-timestamp pair would permanently fork the
chain and hang the profile in Learning forever, so a fresh timestamp is
interposed between the parent's (previousReportTimestamp, reportTimestamp)
pair to keep the chain linear. Timestamps are also stripped of their
monotonic-clock suffix and parsed via a numeric-timezone-abbreviation fallback,
since production timestamps come from time.Now().String(). An unsplittable
chunk is dropped with a stitch chunk in its place to preserve the chain link,
without ever calling back into the container's error path (which would end
learning) and without ever re-stitching a dropped stitch.

Addresses maintainer review feedback on #866:
#866 (review)

Also fixes a pre-existing data race in MockProfileCreator (CreatedProfiles/
CallCount were unsynchronized fields read across goroutines by tests), found
while getting the package's -race gate green for the new tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.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: 3

🧹 Nitpick comments (2)
docs/features/container-profile-split-on-413.md (1)

31-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced blocks.

markdownlint reports MD040 for these blocks. Use text for the diagrams and pseudo-code so the linter passes.

Also applies to: 51-55, 67-70, 76-79, 107-114

🤖 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 `@docs/features/container-profile-split-on-413.md` around lines 31 - 33, Update
the fenced code blocks in the document, including the blocks around the
displayed formula and the referenced sections, to declare the text language. Use
text for diagrams, formulas, and pseudo-code so markdownlint MD040 passes.

Source: Linters/SAST tools

pkg/containerprofilemanager/v1/queue/containerprofile_split_test.go (1)

75-87: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bound the chain walk to avoid a possible test hang.

The walk terminates only when a previousReportTimestamp key is missing. If a future regression produces a cycle in the links, this loop never exits and the test hangs instead of failing. Add an iteration bound.

♻️ Proposed guard
 	cur := origPrev
 	visited := 0
-	for {
+	for visited <= len(rows) {
 		row, ok := byPrev[cur]
 		if !ok {
 			break
 		}
 		cur = row.ReportTimestamp
 		visited++
 	}
🤖 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_split_test.go` around
lines 75 - 87, Bound the loop in the chain walk around cur, byPrev, and visited
so it can iterate at most len(rows) times. Preserve the existing missing-key
termination and assertions, allowing a cycle to exit through the bound and fail
the chain validation instead of hanging.
🤖 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`:
- Around line 174-188: Update the Tests section to use the actual test
identifiers: TestChainHalves_ConsolidatesToParentInterval,
TestChainHalves_ZeroPreviousTimestamp, TestParseReportTimestamp_MonotonicSuffix,
TestSplitProfile_FloorCase, TestSplitProfile_NoProgressGuard,
TestSplitProfile_Deterministic, and
TestStitchChunk_PreservesAssignmentMergedScalars. Point the queue-level tests
TestQueueSplitsProfileOnHTTP413, TestQueueRespectsMaxSplitDepth, and
TestQueueDoesNotStitchAStitch to containerprofile_queue_errors_test.go,
preserving the existing test-category descriptions.

In `@pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors.go`:
- Around line 68-72: Update matchesSentinel to apply the strings.Contains
fallback only when err is a *apierrors.StatusError; retain errors.Is for all
errors and use the status error’s relayed message for the substring comparison.

In `@pkg/containerprofilemanager/v1/queue/containerprofile_queue.go`:
- Around line 446-451: Update the first-half failure branch in the
split-container-profile enqueue flow around enqueueLocked(half(a)) to log at
Warning level, matching the second-half failure path, and increment the
corresponding loss/failure counter before returning after the parent has been
dequeued.

---

Nitpick comments:
In `@docs/features/container-profile-split-on-413.md`:
- Around line 31-33: Update the fenced code blocks in the document, including
the blocks around the displayed formula and the referenced sections, to declare
the text language. Use text for diagrams, formulas, and pseudo-code so
markdownlint MD040 passes.

In `@pkg/containerprofilemanager/v1/queue/containerprofile_split_test.go`:
- Around line 75-87: Bound the loop in the chain walk around cur, byPrev, and
visited so it can iterate at most len(rows) times. Preserve the existing
missing-key termination and assertions, allowing a cycle to exit through the
bound and fail the chain validation instead of hanging.
🪄 Autofix (Beta)

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: ee9a94a3-094e-4a6c-9616-261213b47593

📥 Commits

Reviewing files that changed from the base of the PR and between 97a9f80 and 49f5f19.

📒 Files selected for processing (12)
  • docs/features/container-profile-split-on-413.md
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_errors_test.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_queue_test.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_split.go
  • pkg/containerprofilemanager/v1/queue/containerprofile_split_test.go
  • pkg/metricsmanager/metrics_manager_interface.go
  • pkg/metricsmanager/metrics_manager_mock.go
  • pkg/metricsmanager/metrics_manager_noop.go
  • pkg/metricsmanager/otel/otel_metrics_manager.go
  • pkg/metricsmanager/prometheus/prometheus.go

Comment thread docs/features/container-profile-split-on-413.md
Comment thread pkg/containerprofilemanager/v1/queue/containerprofile_queue.go
@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.154 0.159 +3.2%
Peak CPU (cores) 0.160 0.167 +4.3%
Avg Memory (MiB) 312.246 275.096 -11.9%
Peak Memory (MiB) 314.980 284.492 -9.7%
Dedup Effectiveness

No data available.

@matthyx
matthyx dismissed their stale review July 31, 2026 10:50

Superseded: the requested split-on-413 approach landed in 49f5f19. Re-reviewed there; dismissing so this no longer blocks.

@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-reviewed at 49f5f19. This addresses everything from my previous review, and it's a considerably more careful change than what I asked for — I've dismissed the stale CHANGES_REQUESTED so it isn't blocking. One real finding below, plus one leftover minor.

Verification on my side: go build ./... ok · go test ./pkg/containerprofilemanager/... ./pkg/metricsmanager/... all pass · gofmt/vet clean · -race on the queue package now passes, so the MockProfileCreator race that fails on main got fixed here too — worth calling out in the description, since the previous revision listed it as pre-existing and untouched.

What landed

  • The 413 no longer maps to ObjectTooLargeError. The new failureSplit kind, and the decision to run both sentinel checks before the status-code check so an authoritative aggregate verdict still wins over a bare 413, is exactly the right split of the two meanings.
  • Splits inherit Attempts rather than consuming the retry budget.
  • Drops are counted, labelled with a closed dropReason set, and exported — that closes the "silent drop with no metric" gap I raised.
  • TestQueuePersistsSplitDepth covers Attempts/SplitDepth/IsStitch surviving a Close/reopen, which was the test I'd flagged as missing.

I re-derived the storage-side claims the design rests on rather than trusting the comments, and they hold: mergeContainerProfileTS does assign SeccompProfile/ImageID/ImageTag while appending everything else, and AfterCreate does hardcode hasData=true — so stitchChunk keeping exactly those three scalars is correct, and a naively empty stitch really would have zeroed them on the aggregate.

One thing you can relax: DeflateContainerProfileSpec runs DeflateSortString(Architectures) and DeflateLabelSelectorRequirement(MatchExpressions) before the size count, so the duplication a split introduces in those fields is deduped server-side regardless. Clearing Architectures in the stitch is a nice-to-have rather than load-bearing — the SeccompProfile/ImageID/ImageTag part is the part that matters.

Leftover minor

matchesSentinel still uses an unanchored strings.Contains. I raised this last time and the check ordering is now deliberate, which makes that substring arm load-bearing for the ordering decision: a 413 whose body happened to contain "object is too large" would classify terminal and end learning. QueueManager's own body is "Request entity too large" so it doesn't bite today, but a proxy or ingress returning arbitrary text could. Narrowing the substring arm to *apierrors.StatusError — where you know the message is a relayed sentinel and errors.Is genuinely can't work — would remove the last text-matching hazard without losing the prefixed-sentinel case.

The chainHalves shim, its TEMPORARY marker and the pointer to kubescape/storage#352 are well judged, as is deriving the base name from storage's own file.SplitProfileName rather than reimplementing the suffix heuristic.

defer qd.mu.Unlock()

if err := qd.enqueueLocked(half(a)); err != nil {
logger.L().Debug("failed to enqueue the first half of a split container profile",

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.

The two enqueue-failure paths here are asymmetric, and the first one is the worse of the two.

If the first half fails, this logs at Debug and returns — so b is never enqueued either. The parent was already dequeued, so both halves are gone. The second-half failure loses one half and correctly logs a Warning naming the forked chain; the first-half failure loses the entire chunk and is quieter about it. Neither path increments chunksDropped or calls ReportContainerProfileChunkDropped, so the new metric can't see either.

enqueueLocked returns an error on !qd.running — reachable during Close while processAllItems is still mid-flight — or on a dque disk error. Rare, but the consequence is precisely what this PR exists to prevent: a forked chain, which by the analysis in #871 leaves the profile in Learning permanently.

Suggest treating it like the second-half case: Warning, a drop-metric bump, and ideally a stitch carrying the parent's original (previousReportTimestamp, reportTimestamp) pair — at that point nothing from the parent reached the queue, so the parent's own interval is exactly what needs repairing. That also makes the two branches read the same way, which matters for a path this hard to exercise.

matthyx and others added 2 commits July 31, 2026 12:59
- matchesSentinel's strings.Contains fallback now only fires for
  *apierrors.StatusError, where the message is known to be a sentinel
  relayed verbatim by the apiserver. Previously it ran against every
  error shape, so any error whose text happened to embed "object is
  too large" or "object is completed" (e.g. from a proxy or ingress)
  would misclassify as terminal and end learning for the container -
  now that sentinel-checks-before-413-check is a deliberate ordering,
  this substring arm had become load-bearing enough to matter.

- requeueSplit's first-half enqueue failure was logged at Debug with
  no metric, even though the parent was already dequeued and so both
  halves are lost - a total loss, strictly worse than the second-half
  case next to it, which correctly logs a Warning. Both paths now log
  at Warning and increment the chunksDropped/drop-metric counters, and
  the first-half case additionally enqueues a stitch chunk carrying
  the parent's original (previousReportTimestamp, reportTimestamp]
  interval, since nothing of the parent reached the queue in any form.

- Corrected the docs page's Tests section: it cited test names and a
  file path that don't match what was actually written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lit's lost-halves path

Both behavior changes in 9a9ce80 shipped without a regression test, as
pointed out on review: reverting either fix would have left every existing
test green.

- TestClassifyFailure gains a case for a plain, non-StatusError error whose
  text merely mentions a sentinel ("object is too large") - exactly the
  input whose classification changed from terminal to retryable, and the
  one matchesSentinel's old unanchored strings.Contains would have caught.

- New TestEnqueueLocked_ReturnsErrQueueNotRunning and
  TestRequeueSplit_QueueNotRunningDropsBothHalvesAndAttemptsStitch exercise
  the previously-uncovered ErrQueueNotRunning path white-box (tests live in
  package queue): flipping qd.running directly reproduces the state
  reachable during Close while processAllItems is still mid-flight, and
  confirms the first-half enqueue failure is now logged, counted, and
  triggers a stitch attempt rather than silently returning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.154 0.000 -100.0%
Peak CPU (cores) 0.162 0.000 -100.0%
Avg Memory (MiB) 337.015 0.000 -100.0%
Peak Memory (MiB) 339.023 0.000 -100.0%
Dedup Effectiveness

No data available.

@github-actions

Copy link
Copy Markdown

Performance Benchmark Results

Node-Agent Resource Usage
Metric BEFORE AFTER Delta
Avg CPU (cores) 0.225 0.206 -8.7%
Peak CPU (cores) 0.236 0.214 -9.1%
Avg Memory (MiB) 346.937 274.291 -20.9%
Peak Memory (MiB) 350.090 282.938 -19.2%
Dedup Effectiveness

No data available.

@matthyx matthyx added the release Create release label Jul 31, 2026
@matthyx
matthyx merged commit ce05eec into main Jul 31, 2026
30 of 31 checks passed
@matthyx
matthyx deleted the fix/container-profile-queue-413 branch July 31, 2026 12:24
@matthyx matthyx moved this from WIP to To Archive in KS PRs tracking Jul 31, 2026
aryanghai12 added a commit to aryanghai12/node-agent that referenced this pull request Aug 5, 2026
…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>
matthyx pushed a commit that referenced this pull request Aug 7, 2026
…and retry exhaustion (#884)

* fix(containerprofile): repair the report chain on LRU eviction and MaxAttempts exhaustion (#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 #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>
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

release Create release

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

node-agent retries oversized ContainerProfiles forever: HTTP 413 from storage is not classified as too-large

2 participants