Skip to content

fix(sbommanager): decouple shared-data wait from single-worker pool to stop node-wide SBOM stall (#850)#859

Open
Aaradhya-07 wants to merge 3 commits into
kubescape:mainfrom
Aaradhya-07:fix/850-sbom-shared-data-wait-hol-block
Open

fix(sbommanager): decouple shared-data wait from single-worker pool to stop node-wide SBOM stall (#850)#859
Aaradhya-07 wants to merge 3 commits into
kubescape:mainfrom
Aaradhya-07:fix/850-sbom-shared-data-wait-hol-block

Conversation

@Aaradhya-07

@Aaradhya-07 Aaradhya-07 commented Jul 21, 2026

Copy link
Copy Markdown

Overview

Current behavior: A single short-lived container can silently halt SBOM generation node-wide. SbomManager runs a single-worker pool, and the shared-container-data wait executed on that worker via backoff.Retry(context.Background(), …) with no timeout — running to backoff v5's 15m DefaultMaxElapsedTime on a context that is never cancelled. Since shared data is deleted on the container-remove event, a container that exits before its data is populated holds the sole worker for the full window, head-of-line-blocking SBOM generation for every other container on the node.

Future behavior: The shared-data wait is decoupled from the single-worker pool. ContainerCallback now performs the wait in a per-container goroutine — bounded by a timeout and cancelled early on the container-remove event — and submits only the actual SBOM work to the pool once metadata is available. A stuck/short-lived container can no longer occupy the worker (so other containers' SBOMs proceed unblocked) nor park a goroutine past its own lifetime.

Additional Information

  • waitForSharedContainerData now takes a context.Context, bounded via context.WithTimeout(s.ctx, maxWaitForSharedContainerData) instead of context.Background(). The bound is 15m, tied to the producer's budget: the shared data is populated by ContainerWatcher.setSharedWatchedContainerData, which retries via cenkalti/backoff NewExponentialBackOff (15m DefaultMaxElapsedTime). A shorter consumer window would give up while the producer is still succeeding, silently dropping the SBOM.
  • The per-container wait is cancelled on the container-remove event via a map[containerID]context.CancelFunc guarded by a mutex (populated when the wait starts, drained/cancelled in ContainerCallback's EventTypeRemoveContainer branch). This keeps the fix from trading a worker stall for an unbounded goroutine leak — a short-lived container's wait ends at its actual lifetime rather than parking a goroutine (retaining notif/mounts/imageStatus) for the full timeout. A guard also prevents new work being enqueued to the pool during shutdown.
  • The pool-of-1 invariant is intentionally preserved — I did not raise the worker count, because scanRetries is documented as mutex-free precisely because the pool size is 1 (pool > 1 would introduce a data race).
  • The shared-data-wait failure log now distinguishes cause: Debug when the wait ends in context.DeadlineExceeded/Canceled (the benign SbomManager logs "container not found in shared data" at ERROR when a short-lived container exits early #848 outcome for a container that exits before its data lands, including cancellation on container-remove), and Error only for genuinely unexpected failures. It also gained .Ctx(s.ctx) and namespace/pod/container fields for consistency with the rest of the file.
  • Out of scope: SBOM generation remains serial (pool size 1) by design — that was not the bug; cmd/main.go's non-cancellable root context and the pre-existing Set-after-Delete ordering race between the producer and DeleteSharedContainerData are left untouched.

How to Test

  • Run the package tests (with the race detector): go test -race ./pkg/sbommanager/v1/
  • Test_awaitAndSubmit_StuckContainerDoesNotBlockOthers is the SBOM generation stalls node-wide when a short-lived container blocks the single-worker SbomManager pool #850 regression guard for the fix itself — container A's data never arrives while B's is present, and it asserts awaitAndSubmit returns without blocking and B's work reaches the pool worker while A is stuck (deleting the goroutine wrapper fails it).
  • Test_waitForSharedContainerData_HonorsContextDeadline asserts the wait stops at the context deadline with context.DeadlineExceeded rather than backoff's 15m default; Test_cancelWait_StopsInFlightWait covers container-remove cancellation; Test_waitForSharedContainerData_ReturnsWhenDataArrives covers the happy path.
  • In-cluster: deploy a frequently-firing short-lived workload (e.g. a CronJob every few minutes) alongside a newly-deployed long-lived workload on the same node; confirm the long-lived workload's sbomsyft CR is still written promptly instead of stalling.

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

Summary by CodeRabbit

  • Bug Fixes
    • Added a 10-minute maximum wait for shared container data to avoid prolonged delays.
    • Container processing now performs shared-data waiting asynchronously and is bounded by the provided timeout/cancellation.
    • Waiting behavior was updated so retries stop promptly when the context expires.
  • Tests
    • Added regression tests to verify context deadlines are honored and shared container data is returned immediately when already available.

…o stop node-wide SBOM stall (kubescape#850)

Signed-off-by: Aaradhya-07 <aaradhya07n@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The SBOM manager now bounds shared-container-data waits to ten minutes, propagates caller cancellation through retry logic, and delays worker-pool submission until shared data is available. Tests cover timeout handling and cached-data success.

Changes

SBOM shared-data wait

Layer / File(s) Summary
Context-aware shared-data retrieval
pkg/sbommanager/v1/sbom_manager.go, pkg/sbommanager/v1/sbom_manager_sharedwait_test.go
waitForSharedContainerData now accepts a caller context and tests cover deadline cancellation and successful cached-data retrieval.
Bounded callback orchestration
pkg/sbommanager/v1/sbom_manager.go
ContainerCallback waits asynchronously with a ten-minute timeout before submitting metadata-aware processing to the worker pool.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ContainerCallback
  participant waitForSharedContainerData
  participant SbomManagerWorkerPool
  ContainerCallback->>waitForSharedContainerData: wait with timeout context
  waitForSharedContainerData-->>ContainerCallback: shared container data
  ContainerCallback->>SbomManagerWorkerPool: submit processContainerWithMetadata
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses #850 by moving the wait out of the single-worker pool and bounding it with a timeout while keeping SBOM generation serial.
Out of Scope Changes check ✅ Passed The changes stay focused on the SBOM stall fix and its regression tests, with no obvious unrelated scope added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: decoupling the shared-data wait from the single-worker pool to prevent SBOM stalls.
✨ 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.

🧹 Nitpick comments (1)
pkg/sbommanager/v1/sbom_manager.go (1)

260-264: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Include context and error details in the log message.

To maintain consistency with the rest of the file and aid in debugging, consider passing the context to the logger, logging the actual error, and including the namespace and pod name (available on notif.Container.K8s).

♻️ Proposed log enhancement
 		if err != nil {
-			logger.L().Error("SbomManager - container not found in shared data",
+			logger.L().Ctx(ctx).Error("SbomManager - container not found in shared data",
+				helpers.Error(err),
+				helpers.String("namespace", notif.Container.K8s.Namespace),
+				helpers.String("pod", notif.Container.K8s.PodName),
+				helpers.String("container", notif.Container.K8s.ContainerName),
 				helpers.String("container ID", notif.Container.Runtime.ContainerID))
 			return
 		}
🤖 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/sbommanager/v1/sbom_manager.go` around lines 260 - 264, Update the error
logging in the notification handling flow to pass the available context, include
the original error details, and add the container namespace and pod name from
notif.Container.K8s alongside the container ID. Preserve the existing early
return after logging.
🤖 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.

Nitpick comments:
In `@pkg/sbommanager/v1/sbom_manager.go`:
- Around line 260-264: Update the error logging in the notification handling
flow to pass the available context, include the original error details, and add
the container namespace and pod name from notif.Container.K8s alongside the
container ID. Preserve the existing early return after logging.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b5560279-10f7-40dc-a1c3-b90e5aeda00c

📥 Commits

Reviewing files that changed from the base of the PR and between 4da05bf and 146b3ab.

📒 Files selected for processing (2)
  • pkg/sbommanager/v1/sbom_manager.go
  • pkg/sbommanager/v1/sbom_manager_sharedwait_test.go

…xt, error, and pod fields

Signed-off-by: Aaradhya-07 <aaradhya07n@gmail.com>
@matthyx

matthyx commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

@Aaradhya-07 can you solve the conflicts?

@matthyx matthyx moved this to Needs Reviewer in KS PRs tracking Jul 22, 2026
Signed-off-by: Aaradhya-07 <aaradhya07n@gmail.com>
@Aaradhya-07

Copy link
Copy Markdown
Author

@matthyx sorry I didnt see that, resolved .

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

Reviewed at 8511422 (post-merge-with-main). Build, go vet and go test ./pkg/sbommanager/... all pass locally, and the direction is right: moving the wait off the pool-of-1 does remove the node-wide head-of-line block, and keeping workerpool.New(1) is the correct call.

Three things I'd want addressed before merge, plus two notes. Details are in the line comments; summary:

Blocking

  1. The wait window is now shorter than the producer's. maxWaitForSharedContainerData = 10m is compared against containerprofilemanager, but the thing that actually populates this data is ContainerWatcher.setSharedWatchedContainerData, which retries on github.com/cenkalti/backoff v2 with DefaultMaxElapsedTime = 15m. Before this PR the SBOM side also ran to 15m (backoff v5's own DefaultMaxElapsedTime), so the two matched. Now there is a 10–15m window where the producer still succeeds but the consumer has already given up, and ContainerCallback fires once per container add with no retry path — so that container silently never gets an SBOM.

  2. Nothing ever cancels these goroutines. s.ctx traces back to ctx := context.Background() in cmd/main.go:78 — it is never cancelled, so context.WithTimeout(s.ctx, 10m) is always a flat 10-minute wall clock. Combined with the fact that shared data is deleted on container-remove, every short-lived container — the exact population that motivated #850 — parks a goroutine that is guaranteed to fail, for the full 10 minutes, holding notif, mounts and a Verbose: true ImageStatusResponse.

  3. Neither new test covers the actual fix. Both exercise waitForSharedContainerData in isolation. Revert the go func(){...} wrapper (keep the ctx parameter) and both still pass, while the node-wide stall is back.

Non-blocking

  1. The PR description says the benign "container not found in shared data" log from #848 is unchanged — it isn't; it gained .Ctx() and four fields, and it now fires for every short-lived container at Error level. Worth demoting when the cause is context.DeadlineExceeded, and worth correcting the description.

  2. Pre-existing, but it interacts with the premise here: containerCallbackAsync runs go cw.setSharedWatchedContainerData(...) on add and calls DeleteSharedContainerData synchronously on remove, with no ordering between them. A late Set can land after the Delete, which both leaks a permanent cache entry and lets this wait succeed for a container that is already gone — the subsequent scan then runs against stale /proc/<pid> lowerdirs and burns a failureRetries slot. Not for this PR, but it means "data is deleted on remove, so the wait will time out" isn't reliably true.

maxFailureRetryEntries = 1000
failureRetryTTL = 30 * time.Minute
// maxWaitForSharedContainerData bounds the wait for a container's shared data; mirrors containerprofilemanager's MaxWaitForSharedContainerData.
maxWaitForSharedContainerData = 10 * time.Minute

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.

Blocking — this shortens the effective wait from 15m to 10m and can silently drop SBOMs.

The comment justifies 10m by mirroring containerprofilemanager, but the relevant counterpart is the producer, not another consumer. Shared container data is written by ContainerWatcher.setSharedWatchedContainerData (pkg/containerwatcher/v2/containercallback.go:84), which retries with:

err := backoff.Retry(func() error { ... }, backoff.NewExponentialBackOff())

using github.com/cenkalti/backoff v2.2.1, whose NewExponentialBackOff() sets MaxElapsedTime = DefaultMaxElapsedTime = 15 * time.Minute.

Before this PR the consumer side ran on context.Background() and was bounded by backoff v5's own DefaultMaxElapsedTime, also 15m — so producer and consumer windows matched exactly. After this PR the consumer gives up at 10m while the producer keeps retrying to 15m.

Concretely: a pod whose GetWorkload calls fail for ~11 minutes (API server pressure, etcd slowness — i.e. exactly the degraded cluster where you least want to lose data) will have its shared data populated successfully, but waitForSharedContainerData has already returned context.DeadlineExceeded and the goroutine returned. ContainerCallback only fires on EventTypeAddContainer and there is no re-arm path, so that container never gets an SBOM. For a singleton long-lived workload that is permanent until the next restart.

Suggestions, in order of preference:

  • Bound the wait to at least the producer's window (15m), or
  • Export the producer's budget as a single shared constant so the two can't drift, or
  • Keep 10m but re-arm: on the deadline path, re-check the cache once when data eventually shows up (e.g. via the remove/add event) rather than dropping the container.

Whatever the number ends up being, please add a comment tying it to setSharedWatchedContainerData's budget, since that is the constraint that actually matters.

}
s.processContainerWithMetadata(notif, mounts, imageStatus, sharedData.ImageTag, sharedData.ImageID)
// Wait for shared data off the single-worker pool so a short-lived container whose data never arrives can't head-of-line-block SBOM generation node-wide (#850); only real work is submitted to the pool.
go func() {

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.

Blocking — unbounded goroutines, none of which can be cancelled, and most of which are provably futile.

Two facts that together make this worse than it looks:

  1. s.ctx is context.Background(). It comes from CreateSbomManager(ctx, ...) at cmd/main.go:430, and that ctx is context.Background() from cmd/main.go:78 — never wrapped in WithCancel, never cancelled on the SIGTERM path at cmd/main.go:524. So context.WithTimeout(s.ctx, maxWaitForSharedContainerData) is not "bounded by shutdown or 10m", it is always a flat 10-minute timer.

  2. As the PR description itself notes, shared data is deleted on the container-remove event (containercallback.go, EventTypeRemoveContainerDeleteSharedContainerData). So for any container that exits before its data is populated, this wait cannot succeed — it will always burn the entire 10 minutes and then log an error.

That is precisely the #850 population. A node with a CronJob firing every 10s accumulates ~600 live goroutines, each retaining notif (*containercollection.Container), mounts, and an ImageStatusResponse fetched with Verbose: true — that response carries the full image config in its Info map, routinely tens of KB. Nothing bounds this; maxPendingScans doesn't apply on this path.

I don't think "cancel on container-remove" is a follow-up here — it's what keeps the fix from trading one unbounded resource for another, and the signal is already in hand: ContainerCallback receives EventTypeRemoveContainer and currently early-returns at the top. A map[containerID]context.CancelFunc guarded by a mutex, populated here and drained in the remove branch, is roughly 15 lines and turns the worst case from 10 minutes into the container's actual lifetime.

At minimum, please add a concurrency bound (a buffered-channel semaphore around the goroutine) so a burst can't fan out without limit.

Separately, and much smaller: s.pool.Submit at the end has no s.ctx.Done() check, so work is still enqueued during shutdown. That's moot while s.ctx is Background(), but it will matter the moment someone makes it cancellable.


assert.Error(t, err)
assert.Nil(t, data)
assert.Less(t, elapsed, 5*time.Second, "wait must be bounded by the context deadline, not backoff's 15m default")

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.

Blocking — the tests don't guard the regression the PR is about, and this one fails badly when it does fail.

Three separate points:

a) The assertion doesn't prove why the wait returned. assert.Error + assert.Less(elapsed, 5s) is also satisfied if the backoff returns Stop, or if the operation returns a *backoff.PermanentError, or if the mock is misconfigured — none of which are the behavior under test. Add assert.ErrorIs(t, err, context.DeadlineExceeded) so the test actually pins "the context deadline is what stopped it."

b) The 5s bound is a 25x slack on a 200ms deadline, and the failure mode is ugly. If the regression comes back (ctxcontext.Background()), this test does not fail in 5 seconds — elapsed is only measured after waitForSharedContainerData returns, so the call sits there for backoff v5's full 15m DefaultMaxElapsedTime, blows past go test's 10-minute default panic timeout, and takes down the whole package's test binary with a stack dump instead of producing a readable assertion failure. Please run the wait in a goroutine and select on time.After(2*time.Second) so the regression reports as a clean failure, and tighten the bound while you're there.

c) The actual fix is untested. Both tests exercise waitForSharedContainerData in isolation, i.e. the context plumbing. Neither asserts the behavioral claim in the PR title — that the wait no longer occupies the single pool worker. Delete the go func(){...} wrapper in ContainerCallback and call waitForSharedContainerData(ctx, ...) inline: both tests still pass, and the node-wide stall is fully restored.

ContainerCallback is awkward to test directly (it needs /proc and a live CRI socket), so the cheap seam is to extract the goroutine body into something like func (s *SbomManager) awaitAndSubmit(notif, mounts, imageStatus) and assert that (i) it returns to the caller without blocking and (ii) with container A's data never arriving and container B's already present, B's processContainerWithMetadata runs promptly. That's the regression guard #850 needs.

defer cancel()
sharedData, err := s.waitForSharedContainerData(ctx, notif.Container.Runtime.ContainerID)
if err != nil {
logger.L().Ctx(s.ctx).Error("SbomManager - container not found in shared data",

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.

Nit, non-blocking. The PR description says "the benign container not found in shared data log (#848) is unchanged" — it did change here: it gained .Ctx(s.ctx) and three extra fields. The added fields are a clear improvement and the .Ctx() is consistent with the rest of the file (and currently a no-op, since s.ctx carries no span).

The severity is the part worth reconsidering. Post-PR this fires once for every short-lived container that exits before its data lands — a benign, expected outcome that #848 already tracks as noise — and it fires at Error. Consider demoting to Warning, or to Debug specifically when errors.Is(err, context.DeadlineExceeded), keeping Error for genuinely unexpected failures. Either way the description should be corrected.

@Aaradhya-07

Aaradhya-07 commented Jul 23, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review . Pushed a commit addressing them:

  1. Wait window shorter than the producer (10m vs 15m). You're right that the relevant bound is the producer, not the other consumer. Bumped maxWaitForSharedContainerData to 15m to match setSharedWatchedContainerData's NewExponentialBackOff budget, and replaced the misleading "mirrors containerprofilemanager" comment with one that ties the value to the producer's 15m DefaultMaxElapsedTime — since that's the constraint that actually matters. (Went with "bound to the producer's window" rather than exporting a shared constant, since the producer only uses backoff's implicit default and there's no clean constant to export without touching containerwatcher — happy to switch to the shared-constant approach if you'd prefer it.)

  2. Unbounded, uncancellable, futile goroutines. Implemented cancel-on-remove as you suggested: a map[containerID]context.CancelFunc guarded by a mutex, populated when the wait starts and drained/cancelled in the EventTypeRemoveContainer branch of ContainerCallback. So a short-lived container's wait is cancelled at its actual lifetime instead of parking a goroutine (retaining notif/mounts/imageStatus) for the full timeout. Also added an s.ctx.Err() guard before pool.Submit so no work is enqueued during shutdown — moot while s.ctx is Background(), but correct for when it becomes cancellable. I left cmd/main.go's context wiring and the pre-existing Set-after-Delete ordering race alone, since you flagged both as out of scope here.

  3. Tests didn't guard the actual regression. Reworked them.

  • Test_waitForSharedContainerData_HonorsContextDeadline now asserts ErrorIs(err, context.DeadlineExceeded) so it pins why the wait
    stopped, and runs the wait in a goroutine with a 2 now fails cleanly instead of blocking 15m andpanicking the package's test binary.
  • Extracted the goroutine body into awaitAndSubmitt_StuckContainerDoesNotBlockOthers: container A'sdata never arrives, B's is already present, and it asserts awaitAndSubmit returns without blocking and B's work reaches the pool
    worker while A is stuck. Deleting the go wrapper f
  • Added Test_cancelWait_StopsInFlightWait covering the remove-event cancellation.

Nit (log severity). Corrected — the log is now Debug when the cause is context.DeadlineExceeded/Canceled (the benign #848 outcome for a container that exits before its data lands) inely unexpected failures. You're also right the PR description was wrong that the log was "unchanged"; I've updated the description.

go test -race ./pkg/sbommanager/v1/ passes.

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

Labels

None yet

Projects

Status: Needs Reviewer

Development

Successfully merging this pull request may close these issues.

SBOM generation stalls node-wide when a short-lived container blocks the single-worker SbomManager pool

2 participants