fix(plugins): retry transient activation failures and bound boot concurrency (BLO-20410) - #978
fix(plugins): retry transient activation failures and bound boot concurrency (BLO-20410)#978allyblockcast[bot] wants to merge 2 commits into
Conversation
|
🔗 Paperclip issue: BLO-20410 |
1 similar comment
|
🔗 Paperclip issue: BLO-20410 |
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally please review at head Focus areas, in order:
Context: four plugins including |
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Critical Issues (0)Important Issues (3)
Suggestions (0)Strengths
Recommended Action
This PR is authored by |
…urrency (BLO-20410) A plugin whose worker `initialize` RPC blew its 60s budget at pod start was latched at `status='error'` on the first attempt and never retried. Four of eleven installed plugins — including `lucitra.plugin-secrets` — sat dead for 9+ hours; all four recovered from a single manual `POST /api/plugins/<id>/enable` with no code change, no config change and no restart. Two causes, both fixed here: * Boot activation was an unbounded `Promise.allSettled` over every ready plugin, so the whole set contended for one 60s initialize window. Replaced with a bounded worker pool (default 4, `PAPERCLIP_PLUGIN_ACTIVATION_CONCURRENCY`). `loadAll()` is fire-and-forget at boot, so this does not delay readiness. * An initialize timeout was terminal. It is now classified transient and retried with backoff before `markError` latches the row. The classifier is deliberately narrow: `initialize returned ok=false`, manifest errors and missing entrypoints still fail closed on the first attempt. The two retry budgets are counted separately, and an exhausted SDK-install-race retry cannot borrow the transient budget (a crashed-at-import worker matches both classifiers). Does not cover alerting on a plugin left in `error` — that needs a Prometheus gauge plus a rule and is tracked separately. Co-Authored-By: Claude <noreply@anthropic.com>
The activation log line is written once, at activation time, and had already scrolled out of retention by the time BLO-20410 was investigated 9 hours later — `lastError` was the only surviving evidence, and a bare timeout string cannot distinguish boot contention from a real fault. Hoist the retry counters to function scope and append them to the message `markError` persists, so a latched row now says whether the plugin exhausted its retry budget or failed closed on the first attempt without being classified transient. Co-Authored-By: Claude <noreply@anthropic.com>
f19f035 to
7467fa0
Compare
Ally — Consolidated PR ReviewLenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. Prior Findings Dispositioned (3)
Critical Issues (0)Important Issues (3)
Suggestions (0)Strengths
Recommended Action
This PR is authored by |
kkroo
left a comment
There was a problem hiding this comment.
Approved: bounded activation concurrency plus narrow transient retry classification is covered by focused unit tests and preserves terminal plugin faults.
Thinking Path
Linked Issues or Issue Description
Tracked internally as BLO-20410 (Blockcast's Paperclip instance, not a public GitHub issue), so
per CONTRIBUTING.md → "Link Issues or Describe Them In-PR" the bug is described inline below
following
.github/ISSUE_TEMPLATE/bug_report.yml.Partially addresses it — see Not covered below.
What happened?
Four of eleven installed plugins were found sitting in
status: errorfor 9+ hours:All four carried the same
lastError:All four returned to
readywithlastError: nullfrom a singlePOST /api/plugins/<id>/enable—no code change, no config change, no restart. The plugins were healthy; they lost a 60-second race
at pod startup when the whole plugin set initializes at once, and nothing ever tried again.
The failure is transient in cause but permanent in effect, and invisible everywhere you would
normally look: the pod is
1/1 Runningwithrestarts=0and no OOM, node CPU at 44%; nothingalerts on plugin status; and the single explanatory log line is written at activation time and had
long since scrolled out of retention, leaving
lastErroras the only surviving evidence.lucitra.plugin-secrets— the secrets subsystem — was one of the four.Expected behavior
A plugin whose activation times out because of startup contention should be retried automatically
and reach
readywithout human action. A first-attempt timeout should not be terminal, andstartup should not put the entire plugin set into one shared 60s
initializewindow.Steps to reproduce
loadAll()activates all ready plugins at once.RPC call "initialize" timed out after 60000msandlatch
status: error.loadAll()only listsstatus="ready", so an erroredplugin is never reconsidered on any subsequent boot either.
POST /api/plugins/<id>/enableon each errored plugin — every one recovers. Note this endpointis slow: a 15s-timeout probe reports a client timeout and looks like a hang, while the same call
with a 100s budget returns
200.Paperclip version or commit
Observed on the deployed worker tier; fix is based on
masterat34eb2ab3.Deployment mode
Self-hosted Kubernetes, split api/worker tiers. Plugin lifecycle runs on the worker tier only —
app.tsgatesloadAll()topaperclipNodeRole !== "api".What Changed
loadAll()activated every ready plugin through an unboundedPromise.allSettled, putting the whole set into the same 60sinitializewindow. Replaced with a small worker pool (mapWithConcurrency, default 4, overridable viaPAPERCLIP_PLUGIN_ACTIVATION_CONCURRENCY). Order-preserving and settle-semantic, so one bad plugin still cannot abort the rest.loadAll()is fire-and-forget at boot (app.ts), so serializing does not delay readiness.status='error'on the first attempt. The existing worker-spawn retry loop already handled the SDK install race; it now carries a second, independent budget for transient startup failures (2 extra attempts, ~10s added delay — short because each attempt can burn the full 60s budget).initialize returned ok=falseis explicitly excluded — that is the plugin answering "I am broken" inside the budget, i.e. a real fault. Manifest errors and missing entrypoints still fail closed on the first attempt.server/src/__tests__/plugin-activation-retry.test.ts.Verification
The classifier test asserts against the verbatim production
lastErrorstring from the four errored plugins, so it fails if the worker-manager error text drifts.The concurrency test was checked against the old behaviour rather than assumed — replaying the previous
Promise.allSettledpath over 11 items peaks at 11 in flight, against the new bound of 4, so the assertion is not vacuous.Risks
Low-to-moderate, and mostly bounded by the narrowness of the classifier.
error(two extra attempts). Bounded, and only on the transient-looking error shapes.loadAll()is not awaited, and the tuning knob is an env var.stopWorker()-before-startWorker()cleanup, so no new worker-handle leak path is introduced.Not covered
The third
Done whenon the issue — "a plugin left inerrorbeyond a short grace period raises an alert" — is not in this PR. There is currently no Prometheus metric for plugin status at all (metrics.tshas zero plugin series), so that needs a gauge plus a rule in the monitoring-rules GitOps lane. Tracked separately and routed to the Platform/SRE lane; this PR is the runtime half.Model Used
Claude Opus 4.5 (
claude-opus-4-5), extended thinking, via Claude Code with tool use.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template