Skip to content

feat: pubsub worker (builtin migration) - #394

Merged
guibeira merged 12 commits into
mainfrom
feat/pubsub-worker
Jul 6, 2026
Merged

feat: pubsub worker (builtin migration)#394
guibeira merged 12 commits into
mainfrom
feat/pubsub-worker

Conversation

@guibeira

@guibeira guibeira commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds the standalone Rust pubsub registry worker, replacing the built-in iii-pubsub.
  • Trigger/function surface: registers the subscribe trigger type and the bare publish function; boot-time guard refuses to start if the built-in iii-pubsub is still connected.
  • Backends behind an Invoker abstraction: local (in-process, default) and redis (Redis Pub/Sub, cross-instance); selected via the pubsub configuration entry with a gated, gap-free adapter hot-swap (re-subscribe before swap).
  • Adds manifest output, CI/release wiring (create-tag.yml + release.yml), README parity documentation, the registry skill (pubsub/skills/SKILL.md), and connect-or-skip e2e coverage.

Behavior

  • Keeps the trigger type as subscribe; the public function id stays the bare publish (stream bridge compatibility).
  • Delivery payload is the raw published data (no envelope), fire-and-forget; publish with empty topic fails with topic_not_set.
  • Refuses to boot if the built-in iii-pubsub worker is still connected.
  • Deliberate fix vs the builtin: local unsubscribe removes only the given id instead of dropping the whole topic entry (the builtin bug killed co-subscribers). Documented in the README parity table.

Validation

  • cd pubsub && cargo test — 27 unit tests green
  • cd pubsub && cargo fmt --check
  • cd pubsub && cargo clippy --all-targets -- -D warnings
  • cd pubsub && cargo build && ./target/debug/pubsub --manifest | head -5
  • python3 .github/scripts/validate_worker.py --worker pubsub --base-ref main --source-changed '["pubsub"]' — exit 0
  • python3 .github/scripts/build_skills_payload.py --worker pubsub --version 0.1.0 — collected 1 skill file
  • Live e2e against a running engine (III_E2E_REQUIRE=1): publish over the bus fans raw data out to every subscribe trigger; null result; empty topic → topic_not_set
  • Manual smoke: local engine + manual-mode boot + the test_python_pubsub/ README walkthrough → ALL PASS (fan-out to both subscribers, other-topic isolation, empty-topic error, unregister stops delivery)

Notes

  • PR is draft while the migration stack settles.
  • Branch is based on feat/http-worker; rebase onto main once http lands.
  • Release tag pubsub/v0.1.0 is post-merge only, via the Create Tag workflow.
  • Local smoke suite lives in test_python_pubsub/ and is intentionally untracked.

Summary by CodeRabbit

  • New Features

    • Added a new pub/sub worker with topic-based publish and subscribe support.
    • Introduced a selectable pubsub worker option and release trigger coverage for pubsub/v* tags.
    • Added support for switching between local and Redis-backed messaging.
  • Bug Fixes

    • Improved subscription handling so unsubscribe actions remove only the targeted subscription.
    • Empty topics are now rejected with a clear error.
    • Added safer startup and shutdown behavior, including config reloads without unnecessary restarts.

@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jul 3, 2026 8:40pm
workers-tech-spec Ready Ready Preview, Comment Jul 3, 2026 8:40pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new Rust "pubsub" worker crate implementing topic-based publish/subscribe messaging with pluggable local and Redis adapters, hub-based subscription tracking with hot-swap support, trigger/function bridging, configuration integration, CLI entrypoint, documentation, and e2e/unit tests. Updates CI workflows to recognize the new worker.

Changes

Pubsub worker implementation

Layer / File(s) Summary
CI workflow updates
.github/workflows/create-tag.yml, .github/workflows/release.yml
Adds pubsub as a selectable worker and pubsub/v* as a release tag trigger.
Crate scaffolding
pubsub/Cargo.toml, pubsub/build.rs, pubsub/iii.worker.yaml
Defines the iii-pubsub package, build script emitting TARGET, and worker manifest.
Core contracts
pubsub/src/lib.rs, pubsub/src/config.rs, pubsub/src/adapters/mod.rs
Adds trigger/function id constants, PubSubConfig/AdapterEntry schema, and Invoker/PubSubAdapter traits with build_adapter factory.
Local adapter
pubsub/src/adapters/local.rs
Implements in-memory LocalAdapter publish/subscribe/unsubscribe with tests.
Redis adapter
pubsub/src/adapters/redis.rs
Implements RedisAdapter with per-topic listener tasks, single-listener enforcement, and id-checked unsubscribe.
Hub and hot-swap
pubsub/src/hub.rs
Tracks subscriptions, delegates to active adapter, and supports swap_adapter/shutdown with tests.
Trigger bridge and boot
pubsub/src/trigger.rs, pubsub/src/boot.rs
Bridges engine triggers to Hub, guards against built-in iii-pubsub collisions, and orchestrates startup.
Runtime config reload
pubsub/src/configuration.rs
Registers config schema, fetches authoritative config, and hot-reloads adapter on configuration:updated events.
Manifest and binary entrypoint
pubsub/src/manifest.rs, pubsub/src/main.rs
Builds module manifest and implements CLI/main startup/shutdown flow.
E2E tests and docs
pubsub/tests/*, pubsub/README.md, pubsub/skills/SKILL.md
Adds engine connection test helpers, e2e publish/subscribe tests, and worker documentation.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Engine
  participant PubsubBoot as Pubsub Worker
  participant Hub
  participant Adapter as Local/Redis Adapter
  participant Subscriber as Subscriber Function

  Client->>Engine: trigger("subscribe", topic, function_id)
  Engine->>PubsubBoot: register_trigger
  PubsubBoot->>Hub: subscribe(id, topic, function_id)
  Hub->>Adapter: subscribe(topic, id, function_id)

  Client->>Engine: trigger("publish", {topic, data})
  Engine->>PubsubBoot: publish function call
  PubsubBoot->>Hub: publish(topic, data)
  Hub->>Adapter: publish(topic, data)
  Adapter->>Subscriber: invoker.call(function_id, data)
Loading
sequenceDiagram
  participant ConfigWorker as Configuration Worker
  participant PubsubMain as Pubsub Main
  participant ConfigHandler as on-config-change
  participant Hub
  participant NewAdapter

  PubsubMain->>ConfigWorker: register_config(schema, seed)
  ConfigWorker-->>ConfigHandler: configuration:updated
  ConfigHandler->>ConfigWorker: fetch_config
  ConfigHandler->>ConfigHandler: swap_needed check
  ConfigHandler->>NewAdapter: build_adapter(new config)
  ConfigHandler->>Hub: swap_adapter(new adapter)
Loading

Possibly related PRs

  • iii-hq/workers#389: Both PRs modify .github/workflows/release.yml to extend the tag-based release trigger with a new worker-specific pattern (pubsub/v* vs http/v*).

Suggested reviewers: sergiofilhowz, ytallo

Poem

A rabbit hops from topic to topic,
Publishing news, never microscopic. 🐇
Local or Redis, the hub swaps with ease,
Subscribers twitch their whiskers, delivered with breeze.
No more collisions with the builtin old friend—
This warren of pubsub carries messages end to end! 📨✨

🚥 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 identifies the new pubsub worker and its migration from the builtin implementation.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pubsub-worker

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.

@guibeira
guibeira force-pushed the feat/pubsub-worker branch from 77670ee to 890255d Compare July 3, 2026 20:39
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 33 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
pubsub/src/configuration.rs (1)

80-93: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Permanent NOT_FOUND errors are retried like transient failures.

try_get_config_value routes every configuration::get call through trigger_with_retry, which retries all errors (including a legitimate "not found" for a brand-new install) up to 3 times with backoff. On a fresh boot this cost is paid twice (once via should_seed_initial_value, once via fetch_config), adding ~1.5s of pure retry-backoff latency for a case that isn't transient at all.

Separately, the NOT_FOUND detection at Line 90 does substring matching on an error message that has already been reformatted by trigger_with_retry (Line 219-221: "{function_id} failed after {CONFIG_RETRIES} attempts: {last_err}"). This works only because the original error text happens to nest inside the wrapped one — fragile if the bus's error format changes.

Consider a fast-path that treats NOT_FOUND as non-retryable inside trigger_with_retry (or a dedicated helper) rather than exhausting all attempts.

Also applies to: 190-222

🤖 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 `@pubsub/src/configuration.rs` around lines 80 - 93, `try_get_config_value` is
treating a legitimate `NOT_FOUND` from `configuration::get` as a retriable
failure because all calls go through `trigger_with_retry`; update the retry flow
so `NOT_FOUND` is classified as non-retryable immediately (either inside
`trigger_with_retry` or via a dedicated helper) and returns `Ok(None)` without
exhausting retries. Also make the `NOT_FOUND` handling in `try_get_config_value`
rely on the original error signal rather than substring matching against the
wrapped `function_id`/attempts message, so the logic in
`should_seed_initial_value` and `fetch_config` stays fast and robust.
pubsub/src/adapters/local.rs (1)

37-55: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Fire-and-forget delivery has no timeout/bound on invoker.call.

Each publish spawns one detached task per subscriber (documented as parity with the builtin). If a subscriber's handler hangs (e.g. blocked on a lock or an external call), that task never completes — repeated publishes to a stuck subscriber will keep spawning new tasks indefinitely, growing memory/task count with no backpressure or timeout. The same pattern recurs in the Redis listener's per-message spawn (pubsub/src/adapters/redis.rs:118-122).

Consider wrapping invoker.call in tokio::time::timeout so a hung handler doesn't accumulate unbounded background tasks.

♻️ Suggested guard
             tokio::spawn(async move {
-                if let Err(e) = invoker.call(&function_id, data).await {
-                    tracing::debug!(function_id = %function_id, error = %e, "pubsub delivery failed");
-                }
+                match tokio::time::timeout(std::time::Duration::from_secs(30), invoker.call(&function_id, data)).await {
+                    Ok(Err(e)) => tracing::debug!(function_id = %function_id, error = %e, "pubsub delivery failed"),
+                    Err(_) => tracing::warn!(function_id = %function_id, "pubsub delivery timed out"),
+                    Ok(Ok(_)) => {}
+                }
             });

This depends on whether SdkInvoker (in boot.rs, not in this review batch) already enforces a timeout on call — if so this is redundant.

🤖 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 `@pubsub/src/adapters/local.rs` around lines 37 - 55, The fire-and-forget
publish path in local::publish can spawn unbounded detached tasks if
invoker.call hangs, so add a timeout guard around the call before logging
delivery failures. Update the async task in publish so it waits on
tokio::time::timeout with a reasonable bound, and apply the same protection in
the Redis listener’s per-message spawn path for parity. Keep the existing
tracing::debug failure logging, but make sure timeout errors are handled as
delivery failures.
pubsub/src/adapters/redis.rs (1)

27-31: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

ConnectionManager doesn't need an external Mutex.

redis::aio::ConnectionManager is explicitly designed to be cloned and used concurrently on the same underlying connection. Wrapping it in Arc<Mutex<ConnectionManager>> serializes every publish call unnecessarily.

♻️ Suggested refactor
 pub struct RedisAdapter {
-    publisher: Arc<Mutex<ConnectionManager>>,
+    publisher: ConnectionManager,
     subscriber: Arc<Client>,
     ...
 }

Then in publish, use self.publisher.clone() (cheap) instead of locking, per the redis-rs docs: "this manager can be cloned, allowing requests to be sent concurrently on the same underlying connection."

Also applies to: 39-57, 62-74

🤖 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 `@pubsub/src/adapters/redis.rs` around lines 27 - 31, The Redis adapter stores
`ConnectionManager` behind an unnecessary external mutex, which serializes
concurrent publishes. Update `RedisPubSubAdapter` to keep `publisher` as a
clonable `ConnectionManager` handle instead of `Arc<Mutex<ConnectionManager>>`,
and adjust `publish` to use a cloned manager directly rather than locking. Make
the same change anywhere else in `RedisPubSubAdapter` that assumes exclusive
access to `publisher`, while leaving `subscriber`, `subscriptions`, and
`invoker` unchanged.
🤖 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.

Inline comments:
In `@pubsub/src/adapters/redis.rs`:
- Around line 76-134: In subscribe on RedisAdapter, fix the race by making the
topic check and subscription insert atomic under the same subscriptions write
lock, using the existing subscriptions map and SubscriptionInfo to enforce the
single-listener invariant. Also avoid inserting into self.subscriptions until
the spawned listener has actually connected and subscribed successfully; if
get_async_pubsub or pubsub.subscribe fails inside the tokio::spawn block, do not
record the topic as subscribed so future subscribe calls can retry instead of
being blocked by a ghost entry.

In `@pubsub/src/boot.rs`:
- Around line 51-69: SdkInvoker::call currently forwards TriggerRequest with
timeout_ms set to None, which leaves subscriber fan-out work unbounded. Update
the SdkInvoker implementation to accept or derive a finite timeout and pass it
into TriggerRequest, and make sure the pubsub adapter paths that invoke
SdkInvoker still compile and propagate this timeout consistently so hung
consumers cannot stall spawned tasks indefinitely.

---

Nitpick comments:
In `@pubsub/src/adapters/local.rs`:
- Around line 37-55: The fire-and-forget publish path in local::publish can
spawn unbounded detached tasks if invoker.call hangs, so add a timeout guard
around the call before logging delivery failures. Update the async task in
publish so it waits on tokio::time::timeout with a reasonable bound, and apply
the same protection in the Redis listener’s per-message spawn path for parity.
Keep the existing tracing::debug failure logging, but make sure timeout errors
are handled as delivery failures.

In `@pubsub/src/adapters/redis.rs`:
- Around line 27-31: The Redis adapter stores `ConnectionManager` behind an
unnecessary external mutex, which serializes concurrent publishes. Update
`RedisPubSubAdapter` to keep `publisher` as a clonable `ConnectionManager`
handle instead of `Arc<Mutex<ConnectionManager>>`, and adjust `publish` to use a
cloned manager directly rather than locking. Make the same change anywhere else
in `RedisPubSubAdapter` that assumes exclusive access to `publisher`, while
leaving `subscriber`, `subscriptions`, and `invoker` unchanged.

In `@pubsub/src/configuration.rs`:
- Around line 80-93: `try_get_config_value` is treating a legitimate `NOT_FOUND`
from `configuration::get` as a retriable failure because all calls go through
`trigger_with_retry`; update the retry flow so `NOT_FOUND` is classified as
non-retryable immediately (either inside `trigger_with_retry` or via a dedicated
helper) and returns `Ok(None)` without exhausting retries. Also make the
`NOT_FOUND` handling in `try_get_config_value` rely on the original error signal
rather than substring matching against the wrapped `function_id`/attempts
message, so the logic in `should_seed_initial_value` and `fetch_config` stays
fast and robust.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c9bce905-a733-4840-b1f9-88e65d4b532c

📥 Commits

Reviewing files that changed from the base of the PR and between 033db97 and 890255d.

⛔ Files ignored due to path filters (1)
  • pubsub/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (21)
  • .github/workflows/create-tag.yml
  • .github/workflows/release.yml
  • pubsub/Cargo.toml
  • pubsub/README.md
  • pubsub/build.rs
  • pubsub/iii.worker.yaml
  • pubsub/skills/SKILL.md
  • pubsub/src/adapters/local.rs
  • pubsub/src/adapters/mod.rs
  • pubsub/src/adapters/redis.rs
  • pubsub/src/boot.rs
  • pubsub/src/config.rs
  • pubsub/src/configuration.rs
  • pubsub/src/hub.rs
  • pubsub/src/lib.rs
  • pubsub/src/main.rs
  • pubsub/src/manifest.rs
  • pubsub/src/trigger.rs
  • pubsub/tests/common/engine.rs
  • pubsub/tests/common/mod.rs
  • pubsub/tests/e2e_pubsub.rs

Comment thread pubsub/src/adapters/redis.rs
Comment thread pubsub/src/boot.rs
@guibeira
guibeira merged commit f46f630 into main Jul 6, 2026
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant