Skip to content

fix(execution): recover from panics inside FanOutWithConcurrency goroutines (closes #669) - #670

Merged
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/issue-669-fanout-recover
May 22, 2026
Merged

fix(execution): recover from panics inside FanOutWithConcurrency goroutines (closes #669)#670
cristim merged 1 commit into
feat/multicloud-web-frontendfrom
fix/issue-669-fanout-recover

Conversation

@cristim

@cristim cristim commented May 22, 2026

Copy link
Copy Markdown
Member

Summary

Defence-in-depth for internal/execution/FanOutWithConcurrency. Before this change, any panic inside fn (nil deref, type assertion failure, slice OOB in a future refactor) propagated up the unrecovered goroutine and crashed the entire Lambda process. Consequences:

  • Purchase execution row stays at approved with no transition to failed (the aggregator that would record the failure never runs).
  • Lambda invocation terminates abnormally; CloudWatch may not flush the final log lines.
  • User sees a generic Lambda invocation error rather than the actual panic detail.

The fan-out helper now installs a deferred recover() in every goroutine that:

  1. Converts the panic into a structured Err on the per-item Result slot.
  2. Logs the goroutine stack at Error level for post-mortem.
  3. Lets the parent aggregator process the failure exactly like a normal fn-returned error.

Why now?

Current call sites in internal/purchase/execution.go (per-account executeForAccount + per-rec processPurchaseRecommendations) have no obvious panic source today, so this is cheap insurance, not a bug fix. But the failure mode is catastrophic (whole-Lambda crash + stranded execution at approved), and the cost is ~10 lines with zero perf impact. Found during the concurrency-audit pass on #667 + #632; filed as #669.

What changed

  • internal/execution/fanout.go: added a deferred recover() inside the goroutine launched by FanOutWithConcurrency. Captures the panic value + stack via runtime.Stack, logs at Error, and writes a structured error to results[idx].
  • internal/execution/fanout_test.go: new test TestFanOut_PanicInFn panics from fn for one of three items, asserts the panicking item carries an Err containing the panic value AND that the other two items still succeed — i.e. one goroutine's panic doesn't cascade across the fan-out.

Test plan

  • go test ./internal/execution/... ./internal/purchase/... -count=1 -short — 174/174 pass
  • go build ./... — clean
  • Optional post-merge: induce a deliberate panic in a test executeSinglePurchase to verify end-to-end that the execution row transitions to failed (not approved) with the panic visible in exec.Error

Cross-references

Summary by CodeRabbit

  • Bug Fixes
    • Enhanced concurrent processing to gracefully handle failures; individual operations that encounter issues now return errors instead of causing unexpected terminations, ensuring more robust batch processing.

Review Change Stack

…utines (closes #669)

Defence-in-depth for the per-account / per-rec fan-out helper. Before this
change, any panic inside fn (nil deref, type assertion failure, slice OOB
in a future refactor) propagated up the unrecovered goroutine and crashed
the entire Lambda process. Consequences:

  - Purchase execution row stays at 'approved' with no transition to
    'failed' (state machine never runs the aggregator that would record
    the failure).
  - Lambda invocation terminates abnormally; CloudWatch may not flush
    the final log lines.
  - User sees a generic Lambda invocation error rather than the actual
    panic detail.

The fan-out helper now installs a deferred recover() in every goroutine
that converts a panic into a structured Err on the per-item Result slot,
logs the goroutine stack at Error level for post-mortem, and lets the
parent aggregator process the failure exactly like a normal fn-returned
error.

Current call sites in internal/purchase/execution.go (per-account
executeForAccount + per-rec processPurchaseRecommendations) have no
obvious panic source today; the recover() is cheap insurance for the
high-impact failure mode and covers future refactors that might
introduce one.

Regression test (TestFanOut_PanicInFn) panics inside fn for one of
three items, asserts the panicking item carries a non-nil Err containing
the panic value AND that the other two items still succeed (i.e. one
goroutine's panic doesn't cascade across the fan-out).

Closes #669
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a5ad9656-726f-4357-955c-16cb27cbe005

📥 Commits

Reviewing files that changed from the base of the PR and between 6fa5048 and e6fdb42.

📒 Files selected for processing (2)
  • internal/execution/fanout.go
  • internal/execution/fanout_test.go

📝 Walkthrough

Walkthrough

The pull request adds panic recovery to FanOutWithConcurrency, a helper that executes work across multiple goroutines. When the per-item function panics, the goroutine now catches it, logs the panic with a stack trace, and converts it to an error result instead of crashing the process.

Changes

Panic Recovery in Fan-Out Execution

Layer / File(s) Summary
Panic recovery implementation
internal/execution/fanout.go
Adds fmt and runtime imports, then wraps the fan-out goroutine with a defer/recover block that catches panics from fn, logs them with stack trace via runtime.Stack, and stores a corresponding Err in the appropriate results[idx] entry.
Panic recovery test
internal/execution/fanout_test.go
Adds TestFanOut_PanicInFn that invokes FanOut with one item designed to panic and others that succeed, asserting that the panicking item's result contains an error with the recovered panic value and account ID, while non-panicking items return normal results.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

effort/s

Poem

🐰 A goroutine panicked with glee,
But recover() caught it with care,
Now errors stack trace so we see—
No more Lambda crashes from there!
The purchase row marks failure true,
Recovery logs light the path through. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 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 change: adding panic recovery to FanOutWithConcurrency goroutines. It is concise, specific, and directly related to the changeset.
Linked Issues check ✅ Passed The PR meets all primary coding requirements from issue #669: panic recovery is implemented in FanOutWithConcurrency, stack traces are logged at Error level, and a regression test validates panic handling.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing issue #669: panic recovery implementation in fanout.go and a corresponding test in fanout_test.go. No unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-669-fanout-recover

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/all-users Affects every user effort/xs Trivial / one-liner type/bug Defect triaged Item has been triaged labels May 22, 2026
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 8e2f2e7 into feat/multicloud-web-frontend May 22, 2026
4 checks passed
@cristim
cristim deleted the fix/issue-669-fanout-recover branch June 3, 2026 21:54
cristim added a commit that referenced this pull request Jun 8, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jun 8, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jun 19, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jul 5, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jul 10, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jul 17, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jul 19, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jul 19, 2026
)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.
cristim added a commit that referenced this pull request Jul 19, 2026
) (#859)

* chore(reliability): recover() in fire-and-forget goroutines (closes #672)

Add deferred recover() to the three unprotected background goroutines
surfaced in the #669/#670 sibling audit:

- internal/auth/service_apikeys.go: UpdateLastUsed async goroutine
- internal/api/db_rate_limiter.go: cleanup async goroutine
- internal/api/handler_accounts.go: GCP ts.Token() goroutine; panic
  now also sends an error back on tokenChan so the caller returns
  promptly rather than blocking until the 15s deadline fires

Matches the existing pattern in ri_utilization_cache.go and scheduler.go.

* test(reliability): assert fire-and-forget goroutine panic is recovered (refs #672)

Add TestValidateUserAPIKey_UpdateLastUsedPanicIsRecovered to internal/auth
to prove the recover() in the UpdateLastUsed fire-and-forget goroutine works.

The test injects a panicking MockStore.UpdateAPIKeyLastUsed callback and uses
a done channel (closed by the mock Run callback before panicking) to wait for
goroutine execution without time.Sleep. Without the recover() the injected
panic crashes the test binary; with it the process survives and the test passes.

* fix(reliability): cover two missed fire-and-forget goroutines (refs #672)

Add recover() to StartCleanupWorker (db_rate_limiter) and
expireStaleExecutionsAsync (handler_history), both fire-and-forget
goroutines that were absent from the original sweep.

Also use t.Cleanup for AssertExpectations in the panic-recovery test,
consistent with every other mock in the test file.

* fix(lint): avoid rangeValCopy in expireStaleExecutionsSweep

Use index-based loop to avoid copying the PurchaseExecution struct
(304 bytes) on each iteration, as flagged by gocritic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xs Trivial / one-liner impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant