Skip to content

fix(storage): fail fast on lock contention instead of hanging to the request timeout#342

Open
matthyx wants to merge 2 commits into
mainfrom
fix/lock-contention-fail-fast
Open

fix(storage): fail fast on lock contention instead of hanging to the request timeout#342
matthyx wants to merge 2 commits into
mainfrom
fix/lock-contention-fail-fast

Conversation

@matthyx

@matthyx matthyx commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Bounds every entry-acquisition lock/rlock call (CreateWithConn, DeleteWithConn, GetWithConn, get()'s noLock RLock, GuaranteedUpdateWithConn, appendGobObjectFromFile) to a hardcoded 5s child context instead of the outer (~60s) request context.
  • On timeout, returns apierrors.NewServerTimeout(..., retryAfterSeconds=1) instead of apierrors.NewTimeoutError(msg, 0), so the apiserver emits a Retry-After header that client-go's retry logic honors.
  • Migration-path lock re-acquisition sites are deliberately left on the unbounded ctx with their existing error returns — bounding a lock-restore path risks leaving lock accounting unmatched.
  • Adds docs/features/containerprofile-locking-hardening.md documenting the new HTTP 500 ServerTimeout + Retry-After:1 contract.

Context

Root-caused via a multi-agent investigation of armosec/shared-workflows CI failures (relevantCVEs, network_policy_known_servers timing out with 504s under load). This is PR 1 of a stack of independently-revertable fixes; see #2/#3/#4 (stacked on this one) for the CollapseConfig cache, consolidator connection scoping, and pool-exhaustion fail-fast.

Test plan

  • go build ./..., go vet, gofmt clean
  • go test ./pkg/registry/file/... and -race variant pass
  • New unit tests: Test_newContentionTimeoutError, TestStorageImpl_LockContentionReturnsServerTimeout
  • Live Helm e2e validation (built quay.io/matthiasb_1/storage:test, deployed against armosec/shared-workflows system tests): relevantCVEs now passes cleanly (was failing with 504s)

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

Summary by CodeRabbit

  • Bug Fixes

    • Lock contention now fails fast with a consistent server timeout response instead of hanging or returning a generic timeout.
    • Responses now include a short retry hint to help clients back off more smoothly.
    • Several storage operations were updated to apply the same timeout behavior across reads, writes, deletes, updates, and listings.
  • Documentation

    • Added guidance on the new locking behavior, expected timeout responses, and how to verify contention-related issues.

…request timeout

Between v0.0.278 and v0.0.291 several ContainerProfile locking/critical-section
changes increased lock-hold time on the per-key MapMutex (pkg/utils/mutex.go).
Under load this pushed some Create/Get/Update/Delete calls past the k8s
apiserver's outer request timeout (~60s), surfacing as apierrors.NewTimeoutError
with RetryAfterSeconds=0 — a status client-go's retry logic cannot act on, since
no Retry-After header is emitted without a positive value. node-agent then hot-
retried on a plain conflict, producing a retry storm.

Bound every entry-acquisition lock/rlock call (CreateWithConn, DeleteWithConn,
GetWithConn, get()'s noLock RLock, GuaranteedUpdateWithConn,
appendGobObjectFromFile) to a hardcoded 5s child context and return
apierrors.NewServerTimeout(..., retryAfterSeconds=1) instead. This sets
Details.RetryAfterSeconds > 0, so the apiserver emits a Retry-After header
that client-go's retry/backoff logic honors — verified identical to a 504 for
retry-eligibility purposes (both are >= 500). A contended request now fails in
~5s with an actionable backoff signal instead of hanging ~60s with none.

Migration-path lock re-acquisition sites (inside get()'s gob-migration retry,
and appendGobObjectFromFile's write-lock upgrade/restore) are deliberately left
on the unbounded ctx with their existing error returns: bounding a lock-restore
path risks leaving lock accounting unmatched, and correctness beats fail-fast
there.

Adds docs/features/containerprofile-locking-hardening.md documenting the new
HTTP 500 ServerTimeout + Retry-After:1 contract, per repo docs policy for
behavior-visible changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
@matthyx matthyx added the ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin) label Jul 3, 2026
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@matthyx, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d606237-8eb9-4764-b85c-cfa53c7aa7a1

📥 Commits

Reviewing files that changed from the base of the PR and between 1f395bc and 23c356a.

📒 Files selected for processing (2)
  • pkg/registry/file/storage.go
  • pkg/registry/file/storage_test.go
📝 Walkthrough

Walkthrough

Adds a hardcoded 5-second lock acquisition timeout and a new newLockTimeoutError helper in pkg/registry/file/storage.go, applying it across all per-key lock/rlock acquisition call sites to fail fast with a standardized apierrors.NewServerTimeout (HTTP 500, RetryAfterSeconds=1) instead of prior generic timeout errors. Adds corresponding tests and documentation.

Changes

Lock timeout hardening

Layer / File(s) Summary
Timeout helper and error constructor
pkg/registry/file/storage.go
Adds lockTimeout (5s), newLockTimeoutError(op, key, err) returning apierrors.NewServerTimeout with RetryAfterSeconds=1, and resourceFromKey helper.
Apply timed lock context to call sites
pkg/registry/file/storage.go
Wraps lock/rlock acquisition in CreateWithConn, DeleteWithConn, GetWithConn, get, GuaranteedUpdateWithConn, and appendGobObjectFromFile with context.WithTimeout(ctx, lockTimeout), replacing prior apierrors.NewTimeoutError returns with newLockTimeoutError.
Tests for timeout error and contention
pkg/registry/file/storage_test.go
Adds net/http and apimachinery errors imports, plus Test_newLockTimeoutError and TestStorageImpl_LockContentionReturnsServerTimeout validating error shape (500, ServerTimeout, RetryAfterSeconds=1) and contention behavior.
Lock hardening documentation
docs/features/containerprofile-locking-hardening.md
Documents the fail-fast timeout behavior, exception paths for migration re-acquisitions, scope/limitations, and verification steps.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant StorageImpl
  participant MapMutex
  Client->>StorageImpl: Get/Create/Delete/Update request
  StorageImpl->>StorageImpl: context.WithTimeout(ctx, lockTimeout)
  StorageImpl->>MapMutex: Lock/RLock(lockCtx, key)
  alt lock acquired within timeout
    MapMutex-->>StorageImpl: lock granted
    StorageImpl-->>Client: proceed with operation
  else timeout exceeded
    MapMutex-->>StorageImpl: context deadline exceeded
    StorageImpl->>StorageImpl: newLockTimeoutError(op, key, err)
    StorageImpl-->>Client: 500 ServerTimeout, Retry-After: 1
  end
Loading

Possibly related PRs

  • kubescape/storage#307: Modifies the same get/lock-state migration path and per-key lock acquisition logic in pkg/registry/file/storage.go affected by this PR's timed lock context.
🚥 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 summarizes the main storage lock-contention behavior change and matches the PR's intent.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/lock-contention-fail-fast

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.

Copilot AI 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.

Pull request overview

This PR hardens the file-backed storage implementation by bounding lock acquisition to a short backstop timeout and returning a Kubernetes ServerTimeout error with Retry-After so clients can back off instead of hanging until the full apiserver request deadline.

Changes:

  • Add a package-level lockTimeout and wrap key lock/RLock acquisitions with context.WithTimeout(..., lockTimeout) to fail fast under contention.
  • Introduce newLockTimeoutError returning apierrors.NewServerTimeout(..., retryAfterSeconds=1) and use it across CRUD/list lock acquisition sites.
  • Add unit tests validating the new error shape and that lock contention produces ServerTimeout with RetryAfterSeconds=1, plus documentation for the new behavior/contract.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
pkg/registry/file/storage.go Adds a lock-acquisition timeout backstop and returns ServerTimeout + Retry-After on contention.
pkg/registry/file/storage_test.go Adds unit tests covering the new timeout error and contention behavior.
docs/features/containerprofile-locking-hardening.md Documents the new fail-fast lock acquisition behavior and client-facing HTTP semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +72 to +78
func newLockTimeoutError(op, key string, err error) *apierrors.StatusError {
logger.L().Debug("lock acquisition timed out",
helpers.String("op", op), helpers.String("key", key), helpers.Error(err))
return apierrors.NewServerTimeout(
schema.GroupResource{Group: "spdx.softwarecomposition.kubescape.io", Resource: resourceFromKey(key)},
op, 1)
}
Comment on lines 313 to +317
_, spanLock := otel.Tracer("").Start(ctx, "waiting for lock")
beforeLock := time.Now()
err := s.locks.Lock(ctx, key)
lockCtx, lockCancel := context.WithTimeout(ctx, lockTimeout)
defer lockCancel()
err := s.locks.Lock(lockCtx, key)
Comment on lines 392 to +396
_, spanLock := otel.Tracer("").Start(ctx, "waiting for lock")
beforeLock := time.Now()
err := s.locks.Lock(ctx, key)
lockCtx, lockCancel := context.WithTimeout(ctx, lockTimeout)
defer lockCancel()
err := s.locks.Lock(lockCtx, key)
Comment on lines 470 to +474
_, spanLock := otel.Tracer("").Start(ctx, "waiting for lock")
beforeLock := time.Now()
err := s.locks.RLock(ctx, key)
lockCtx, lockCancel := context.WithTimeout(ctx, lockTimeout)
defer lockCancel()
err := s.locks.RLock(lockCtx, key)
Comment on lines 902 to +906
_, spanLock := otel.Tracer("").Start(ctx, "waiting for lock")
beforeLock := time.Now()
err := s.locks.Lock(ctx, key)
lockCtx, lockCancel := context.WithTimeout(ctx, lockTimeout)
defer lockCancel()
err := s.locks.Lock(lockCtx, key)

@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/registry/file/storage_test.go (1)

711-740: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Global lockTimeout mutation is not synchronized.

The test mutates the package-level lockTimeout var (Line 713) and restores it via defer (Line 714). Since lockTimeout is read directly (unguarded) by other lock-acquisition call sites in storage.go, any future test in this package that runs concurrently with t.Parallel() could race on this variable under -race. Currently safe since Go runs same-package tests sequentially absent t.Parallel(), but it's worth a comment or t.Setenv-style guard to prevent someone unknowingly parallelizing this suite later.

🤖 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/registry/file/storage_test.go` around lines 711 - 740, The test mutates
the package-level lockTimeout global in
TestStorageImpl_LockContentionReturnsServerTimeout, which can race with other
lock-acquisition paths if this suite is ever parallelized. Update the test to
avoid unsynchronized global state by using a test-local override pattern or add
a clear guard/comment around the lockTimeout change and restore in the same test
scope. Reference the lockTimeout variable, the
TestStorageImpl_LockContentionReturnsServerTimeout test, and the StorageImpl
lock usage so it is easy to locate and keep the suite safe for future
t.Parallel() usage.
🤖 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/registry/file/storage_test.go`:
- Around line 711-740: The test mutates the package-level lockTimeout global in
TestStorageImpl_LockContentionReturnsServerTimeout, which can race with other
lock-acquisition paths if this suite is ever parallelized. Update the test to
avoid unsynchronized global state by using a test-local override pattern or add
a clear guard/comment around the lockTimeout change and restore in the same test
scope. Reference the lockTimeout variable, the
TestStorageImpl_LockContentionReturnsServerTimeout test, and the StorageImpl
lock usage so it is easy to locate and keep the suite safe for future
t.Parallel() usage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 38070755-faeb-45c2-b983-7c602735d048

📥 Commits

Reviewing files that changed from the base of the PR and between 1f9c8e3 and 1f395bc.

📒 Files selected for processing (3)
  • docs/features/containerprofile-locking-hardening.md
  • pkg/registry/file/storage.go
  • pkg/registry/file/storage_test.go

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: failure

Two real bugs and one style nit flagged by Copilot review on PR #342:

- spanLock (the "waiting for lock" trace span) was only ended on the success
  path in CreateWithConn/DeleteWithConn/GetWithConn/GuaranteedUpdateWithConn.
  On a contended lock that times out, the function returned before
  spanLock.End() ran, leaving the span open/incomplete -- exactly the
  observability signal you'd want when diagnosing the contention this PR
  targets. Move spanLock.End() immediately after the Lock/RLock attempt, on
  both the success and error path.

- newLockTimeoutError always returns ServerTimeout+Retry-After, even when the
  underlying error is context.Canceled (the caller's own ctx was cancelled,
  e.g. a client disconnect) rather than lockTimeout's child context expiring.
  Telling a disconnected client to retry is meaningless and misleading in
  logs/metrics. Distinguish the two: context.Canceled now returns a plain
  InternalError with no Retry-After hint; actual lockTimeout expiry keeps the
  existing ServerTimeout+Retry-After:1 behavior.

- Uses the existing softwarecomposition.GroupName constant instead of
  hardcoding the same literal string, avoiding future drift risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Matthias Bertschy <matthias.bertschy@gmail.com>
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

Summary:

  • License scan: failure
  • Credentials scan: failure
  • Vulnerabilities scan: failure
  • Unit test: success
  • Go linting: failure

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

Labels

ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants