Repository navigation
fix(plugins): deliver each plugin event once across API servers, with retries - #2334
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Check the subscriber index before queueing, so events no plugin subscribes to never fill the intake. - Stop cancels its own loops, waits for them and the deliveries in flight, then stores the events still queued, including those published while the server drains after its context ended. - Record a delivery that succeeded even when shutdown began meanwhile, so it is not delivered again. - Stop waiting for a plugin launch at the delivery deadline, which keeps every attempt inside its lease. - Retry a disabled or missing installation instead of dropping the row: the delivering server's installation cache can lag an enable elsewhere. - Drop a payload that can never decode instead of retrying it. - Store payloads as json, since jsonb refuses a \u0000 escape. - Say in the docs which events can be lost before they are stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Bound only the plugin start by the one-minute deadline, so HandleEvent keeps the host's 10-second event timeout and a hung plugin holds a slot for 10 seconds, not a minute. - Skip a delivery for an installation uninstalled since its subscribers were listed, instead of letting the foreign key fail every subscriber's insert. - Read hub envelopes until Stop unsubscribes, so envelopes published while the server drains reach the outbox like bus events. - Give the test helper that waits for deliveries a deadline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silo Kody — review completeReview finished. Check the inline comments for findings and verify each suggestion against the code and tests. Reviewing changes in Silo
Review settingsReview OptionsThe following review options are enabled or disabled:
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
Merge Risk: ⚪ Minimal · up to Plugin events are now stored in a database outbox and delivered once across API servers, with retries. Earlier concerns about event loss during shutdown and a race with uninstall appear to be addressed. No outstanding merge-blocking problem is established. Pre-merge checks |
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0df92e949d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if !d.mayHaveSubscribers(event.Type) { | ||
| return |
There was a problem hiding this comment.
Refresh subscribers before dropping cross-node events
When an installation is enabled on API server A and server B produces a subscribed event before B's asynchronous plugins_changed handler invalidates its index, this check treats B's stale index as authoritative and drops the event permanently; the later outbox path also uses that same cached index, while A now intentionally ignores B's relayed event. Thus the new single-producer design can lose events during ordinary cross-node lifecycle propagation. Verify current subscribers from the store, or otherwise synchronize the index revision, before excluding a locally produced event.
AGENTS.md reference: AGENTS.md:L13-L18
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real, and narrowed in 4bd9eba. Before this PR, server A delivered B's relayed copy, which covered B's stale index. Now only B stores its events, so B's view decides. The window was longer than a Redis round trip: A published plugins_changed only after the resident reconcile and the provider reloads, which can take seconds. PublishLifecycleChanges now puts the announcement first, right after the dispatcher drops its own index, and B's dispatcher drops its index as soon as the message arrives. A test pins that order.
What remains is the Redis round trip, or up to the one-minute index age if the message is lost. docs/architecture/plugin-event-delivery.md lists it under Limits. I didn't add a store read before excluding each event. That would put 1+N store reads back on every event on every server, the cost #1372 removed, to cover a window that only opens when a plugin is installed or enabled. Leaving this open in case a maintainer wants the stronger guarantee.
Another API server stores the events it produces by its own subscriber index until plugins_changed reaches it. The announcement ran after the resident reconcile and provider reloads, so events produced elsewhere in those seconds missed a newly enabled plugin. Publish it first, after the dispatcher drops its own index, and document the remaining window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quick104
left a comment
There was a problem hiding this comment.
Adversarial review (gpt-6.1-sol, reviewed 0df92e9)
One verified finding: an orderly shutdown can discard an event whose outbox write is in flight. Focused dispatcher and observer tests pass with -race; the finding reproduces with a cancellation-aware outbox test double. No local Postgres was available. I also inspected follow-up 4bd9eba; it does not change this storage path.
AI disclosure: Codex in T3 Code, gpt-6.1-sol.
storeEvents wrote an event it had taken from the intake with the server's context, so a shutdown that cancelled it mid-insert dropped the event, and Stop only drains what is still queued. Store each event with a bounded context detached from shutdown; Stop already waits for the loop before it drains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Log and store a failed delivery's error through logredact.SanitizeText: a plugin's error text can carry credentials it was given. - Join plugin_installations FOR KEY SHARE when storing deliveries, so an uninstall committing between the existence check and the foreign-key check cannot fail the insert for every other subscriber. - Group Silo imports apart from third-party ones, as main's goimports setting now requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Silo Kody — review completeReview finished. Check the inline comments for findings and verify each suggestion against the code and tests. Reviewing changes in Silo
Review settingsReview OptionsThe following review options are enabled or disabled:
|
v0.25.0 is the first SDK release with HandleEventRequest.event_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Closes #2314
Related issue: Silo-Server/silo-plugin-sdk#40
Validation tasks: none
With more than one API server, every
event_consumer.v1plugin received each event once per server, so a plugin that sends a notification for an event sent one per server. A delivery that failed, for example while the plugin restarted, was logged and dropped. On a single server without Redis, events published on the bus never reached plugins at all. This change stores each event once, on the server that produced it, in a Postgres outbox, and any API server delivers it with retries.Every API server's dispatcher subscribed to the
silo:catalog,silo:adminandsilo:playbackbus channels, which Redis hands to every subscriber. It also read everyevents.Hubenvelope, and each hub relays envelopes to every other server, so plugin-published events were duplicated the same way.Approach
plugin_event_deliveriesrow per subscribing installation. Bus events come from acache.ObservedEventBuswrapper that sees what the process itself publishes, with or without Redis. Hub envelopes are taken only when theirSourceIDis the local hub's. Both paths check the subscriber index first, so unsubscribed events cost a map lookup.MATERIALIZEDCTE usingFOR UPDATE SKIP LOCKEDand a two-minute lease, callsHandleEventon its own plugin process, and deletes the row on success. A failure is retried after 10 s, 30 s, 1 min, 3 min and 15 min, then dropped with a warning. A row whose server died mid-delivery is claimed again when its lease runs out, and outcome writes are guarded by the attempt count, so a server whose lease lapsed cannot finish a row another server has claimed since.event_idon every attempt, and no ordering.HandleEventRequestgainsevent_idin silo-plugin-sdk v0.25.0 (feat(eventconsumer): give each delivered event a stable event_id silo-plugin-sdk#41), which also documents this contract;go.modmoves to that release.docs/architecture/plugin-event-delivery.mdrecords the invariants and the cases where an event can be lost before it is stored.Validation
-race: two servers sharing one bus and one outbox deliver each bus event and each hub envelope once. The same test setup onmaindelivered each event twice. Other tests cover retries that keep theevent_id, giving up after the last retry, a disabled installation being retried, a delivery claimed by a dead server being delivered by another after the lease, events published during shutdown still being stored, and unsubscribed events never being queued.scripts/ci/db-contracts.txt: a claim takes at most its limit, concurrent claimers never share a row, only the latest claim can finish a row, a row for an uninstalled installation does not fail the others' insert even when the uninstall races it, and a payload with a\u0000escape is stored.main, one setting change reached the plugin twice with noevent_id, and a failed delivery was never retried. On this branch the change reached it once, and a failed delivery was retried with the sameevent_idand delivered.gofmt,go vet,make lint-changed, and the generated-contract, fixture and docs checks pass.make test-gopasses except two real-ffmpeg tests ininternal/mediasample. Those fail the same way onmainwith this host's ffmpeg. Web checks were not run because the diff does not touchweb/.Evidence: https://evidence.siloserver.org/r/silo-server/issue-2314/
Evidence
No user-visible change. The probe plugin's before and after logs are on the evidence page linked above.
Risks
plugin_installations. Existing tables are not changed.event_consumer.v1.event_id.plugins_changedreaches it. That is a Redis round trip, or up to a minute if the message is lost. The announcement now goes out before the slow lifecycle hooks.Follow-ups:
PublishEventToInstallation).User manual: unaffected. It does not describe plugin event delivery.
Checklist
AI Disclosure
Harness: Claude Code in T3 Code
Tool(s): Claude Code CLI, Claude Code subagents for code review
Model(s): claude-opus-5-5; claude-sonnet-5-5 for two review subagents
Involvement: AI-assisted
Adversarial review: A multi-agent review covered the diff's lines, callers, contract and failure modes, cleanup, and validation impact, followed by a sweep. It found:
WaitGrouprace inStop;jsonbrefusing\u0000;All are fixed on this branch. A cross-provider review (Codex, gpt-6.1-sol, reviewed 0df92e9) found that an event already taken from the queue was lost when shutdown cancelled its write; fixed in e8b1229. The Codex connector's finding on peer subscriber staleness is narrowed in 4bd9eba and answered in its thread. Kody's findings on an uninstall racing the insert and on plugin error text reaching logs are fixed in 68c16a0.
🤖 Generated with Claude Code