Skip to content

refactor(sdk/event): subject-routed Bus, Envelope, MemoryBus with backpressure & route cache#31

Merged
lIang70 merged 6 commits into
mainfrom
refactor/sdk-event-bus
Apr 23, 2026
Merged

refactor(sdk/event): subject-routed Bus, Envelope, MemoryBus with backpressure & route cache#31
lIang70 merged 6 commits into
mainfrom
refactor/sdk-event-bus

Conversation

@lIang70

@lIang70 lIang70 commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Reworks sdk/event from the legacy Event{Type,…} + EventFilter API into a subject-routed publish/subscribe bus that is cross-process ready, fixes long-standing concurrency bugs, and adds a route cache that makes hot-subject dispatch O(1) regardless of subscriber count.

The legacy API is kept side-by-side under a Legacy* prefix (LegacyMemoryBus, LegacyEventBus, Event, EventType, EventFilter, …) with Deprecated: godoc and per-symbol Migration: snippets. Removal is scheduled for v0.2.0 and is physically a single git rm sdk/event/deprecated*.go.

Downstream sdk/graph/executor and sdk/kanban are migrated to the new API in this PR. internal/eventlog bridge and the contracts/eventgen codegen are deliberately deferred to follow-up PRs (see scope notes below).

What's new

New surface (sdk/event)

  • Bus — minimal interface: Publish(ctx, Envelope) → error, Subscribe(ctx, Pattern, …SubOption) → (Subscription, error), Close() → error. Returns ErrBusClosed after shutdown; Close is idempotent.
  • Envelope — JSON-friendly carrier with json.RawMessage payload (zero-copy across in-process and remote implementations), well-known headers (HeaderRunID, HeaderNodeID, HeaderActorID, HeaderGraphID, HeaderTenant), and typed accessors.
  • Subject / Pattern — dot-delimited routing keys with NATS-style wildcards (* single segment, > trailing). Validate() enforces well-formedness.
  • Observer — three-call lifecycle (OnPublish / OnDeliver / OnDrop) replacing the single WithDropCallback. Invoked outside every bus lock; observers may not call back into the bus.
  • BackpressurePolicyDropNewest (default) / DropOldest / Block, configured per subscription via WithBackpressure.
  • NoopBus — the documented zero implementation, kept next to the contract for nil-check ergonomics.

MemoryBus implementation highlights

  • Two-phase Close that fixes the legacy send-on-closed-channel panic: signal done first → drain inflight and per-sub senders wait-groups → close subscriber channels.
  • Lock-free Block backpressure: parked publishers register on a per-sub senders wait-group before releasing the bus RLock, so Close can wait deterministically.
  • Route cache (this PR's last commit): memoises Subject → []*memSub, guarded by a secondary RWMutex with strict lock ordering b.mu → routeCacheMu. Cache is wholesale invalidated on any subscriber churn, so it can never serve stale entries. Predicates (WithPredicate) are still evaluated per publish because they may inspect the envelope.
  • WithRouteCacheSize(n) lets callers size or disable the cache.

Benchmarks (M2 Max, hot subject, no matching subscriber)

Subscribers Before (no cache) After (cache hit) Speedup
100 781 ns/op, 32 B, 1 alloc 55 ns/op, 0 B, 0 alloc ~14×
1000 9102 ns/op, 32 B, 1 alloc 55 ns/op, 0 B, 0 alloc ~165×

Dispatch cost is now decoupled from subscriber count for hot subjects.

File layout

sdk/event/
  bus.go             — Bus / Subscription / SubOption contract + NoopBus
  memory.go          — MemoryBus, the in-process implementation
  envelope.go        — Envelope value type + well-known headers
  subject.go         — Subject / Pattern + matchSegs hot-path matcher
  observer.go        — Observer + BackpressurePolicy + DropReason
  deprecated.go      — All v0.1.x compatibility shims (single-file delete in v0.2.0)
  *_test.go          — Includes memory_route_cache_test.go and memory_bench_test.go

Downstream migration (in this PR)

  • sdk/graph/executor publishes via event.Bus; subjects formed by helpers in subjects.go (e.g. subjGraphStart, subjNodeStreamDelta).
  • sdk/kanban publishes via event.Bus; EventKind constants now hold dot-separated kind values, surfaced both in Subject and the HeaderKanbanKind envelope header.

Compatibility

  • Source-compatible for the legacy types — they keep working with Deprecated warnings and per-symbol Migration: examples in godoc.
  • Behavioural breaks are intentional and documented:
    • LegacyEventBus and the new Bus are not assignable to each other; field types must be migrated explicitly.
    • The legacy "empty EventFilter.ActorID is broadcast" semantics is removed in the new API; subscribers should use Pattern + WithPredicate instead.
    • sdk/kanban EventKind values changed to the new dot-form (e.g. kanban.task.submitted); they were not exported across module boundaries before.

Tests

  • Full new test suite for sdk/event: subject validation, pattern matching (including malformed > totality), three backpressure policies, observer lock-free contract, Close race regression, route cache (hit, Subscribe/Unsubscribe invalidation, predicate-not-cached, LRU cap eviction, disabled cache), and a -race goroutine-churn stress test.
  • sdk/graph/executor and sdk/kanban test suites updated to assert the new envelope shape.
  • make vet + make test (with -race) green for sdk and sdkx modules. (examples/voice-pipeline has a pre-existing unrelated go mod tidy issue on main.)

Scope

Included (commits in this PR, oldest → newest):

  1. refactor(sdk/event): rename legacy bus types with Legacy* prefix
  2. feat(sdk/event): introduce Envelope/Subject/Pattern + MemoryBus with Observer and backpressure
  3. refactor(sdk/graph/executor): migrate to new event.Bus API
  4. refactor(sdk/kanban): migrate to new event.Bus API
  5. test(sdk/event): backfill coverage for low-coverage paths
  6. refactor(sdk/event): split Bus contract from MemoryBus, add route cache

Deferred to follow-up PRs (kept out to keep this PR reviewable):

  • cmd/eventgen codegen + contracts/events/*.yaml subject: field — sdk/event/subjects to be machine-generated.
  • internal/eventlog bridge rewrite to consume the new bus / Pattern.
  • internal/platform, internal/bootstrap, internal/gateway subscription migrations.
  • MIGRATION.md aggregator + CI guard for deprecated-symbol references.

Test plan

  • cd sdk && go test -race ./event/... -count=10
  • cd sdk && go test -race ./... (all modules)
  • cd sdkx && go test -race ./...
  • cd sdk && go test -bench=BenchmarkPublish -benchmem ./event/... (numbers above)
  • make vet green
  • CI green

Made with Cursor

lIang70 added 6 commits April 23, 2026 13:51
Pure rename. Behaviour and exported field shapes are unchanged.

Renamed (in sdk/event/bus.go):
  MemoryBus           -> LegacyMemoryBus
  EventBus            -> LegacyEventBus
  Subscription        -> LegacySubscription
  NoopBus             -> LegacyNoopBus
  MemoryBusOption     -> LegacyMemoryBusOption
  SubscribeOption     -> LegacySubscribeOption
  NewMemoryBus        -> NewLegacyMemoryBus
  WithBufferSize      -> LegacyWithBufferSize
  WithDropCallback    -> LegacyWithDropCallback

Kept as-is (will be removed wholesale in v0.2.0):
  Event, EventType, EventFilter, all EventXxx constants

Every renamed symbol carries a // Deprecated: note. The new subject-routed
Bus / Envelope / MemoryBus types will land in subsequent commits and take
over these short names.

In-tree call sites updated:
  sdk/graph/executor/{executor,runner,parallel}.go (+ tests)
  sdk/kanban/{board,events,kanban,helpers,board_test,kanban_test,helpers_test}.go

Verified: cd sdk     && go test ./... -count=1   # all green
  cd sdkx    && go build ./...           # ok
  cd plugin  && go build ./...           # ok
  cd examples/voice-pipeline && go build ./...  # ok
Made-with: Cursor
…Observer and backpressure

New types (live alongside the deprecated Legacy* family from the previous
commit; no behaviour change to existing call sites):

  Envelope         cross-process-friendly carrier; Payload is json.RawMessage
                   so in-memory and remote bus implementations behave
                   identically (bytes in / bytes out)
  NewEnvelope/MustEnvelope/Decode helpers; well-known header keys
  (HeaderRunID/NodeID/ActorID/GraphID/Tenant) and typed accessors

  Subject          dot-delimited routing key, e.g. graph.run.r1.start
  Pattern          NATS-style matcher; '*' matches one segment, '>'
                   matches one or more trailing segments
  Subject.Validate / Pattern.Validate / Pattern.Matches

  Bus / Subscription           new primary interface
  SubOption                    WithBufferSize, WithBackpressure, WithPredicate
  BackpressurePolicy           DropNewest (default), DropOldest, Block
  Observer                     OnPublish / OnDeliver / OnDrop, invoked
                               outside any bus lock; replaces the legacy
                               WithDropCallback hook
  DropReason                   BufferFull / Closed
  SubscriptionID               opaque per-bus identifier

  MemoryBus                    in-process implementation
  NewMemoryBus(opts...)        WithObserver option
  NoopBus / NewNoopBus

Concurrency fixes baked in (vs LegacyMemoryBus):

  * Observer callbacks are deferred into a local slice and fired after the
    bus RLock is released — matches the documented "no callbacks under
    bus lock" contract.

  * MemoryBus.Close runs a multi-phase shutdown:
        1. write-lock, mark closed, snapshot subscribers
        2. signalClose() each sub  (closes sub.done so Block-backpressure
           publishers exit instead of staying parked)
        3. inflight.Wait()         (drains every in-flight Publish)
        4. for each sub: senders.Wait() then closeChanFromBus()
    This fixes the legacy "send on closed channel" race (P8) without
    introducing the inverse "Close hangs forever on a parked Block
    publisher" deadlock.

  * memSub.Close (user-driven) runs the same two-phase shutdown scoped
    to one subscription: removeSub → close(done) → senders.Wait() →
    close(ch). signalOnce / chanOnce keep the bus-driven and user-driven
    paths from racing each other.

  * sub.senders sync.WaitGroup tracks Block-path publishers that have
    released the bus RLock. Non-blocking DropNewest / DropOldest sends
    complete while still holding RLock and therefore do not need to
    register on senders.

Tests (all under -race -count=10):

  subject_test.go               Subject/Pattern validation + matching
                                including all wildcard edge cases
  envelope_test.go              struct/raw/nil payload, headers helpers,
                                JSON roundtrip, MustEnvelope panic path
  memory_test.go                pattern routing, predicate, every
                                backpressure policy, observer counts,
                                ctx cancel, Close idempotency, plus the
                                two regression tests:
                                  TestMemoryBus_Close_NoSendOnClosedChannelPanic
                                    (race-detector verifies P8 fix)
                                  TestMemoryBus_Close_UnblocksParkedPublisher
                                    (verifies the deadlock fix above)

Both regression tests were proven to actually catch the bugs they target
by temporarily disabling the corresponding fix and observing the failure.

Made-with: Cursor
Switches every Publish call site in the graph executor from the
deprecated LegacyEventBus / Event API to the new Bus / Envelope API
landed in the previous commit.

Subject convention used by this package (sdk-internal; no relation to
any flowcraft business schema):

  graph.run.<runID>.start
  graph.run.<runID>.end
  graph.run.<runID>.parallel.fork
  graph.run.<runID>.parallel.join
  graph.run.<runID>.node.<nodeID>.start
  graph.run.<runID>.node.<nodeID>.complete
  graph.run.<runID>.node.<nodeID>.error
  graph.run.<runID>.node.<nodeID>.skipped
  graph.run.<runID>.node.<nodeID>.stream.delta

Identifier safety: runID and nodeID are passed through sanitiseID, which
substitutes '_' for any '.', '*', '>' bytes so user-supplied IDs cannot
fragment the Subject or accidentally turn into a wildcard. Empty IDs
become "_" to keep the segment count stable.

Header propagation on every emitted Envelope (replaces the flat fields
on the legacy Event struct):

  HeaderRunID    cfg.runID
  HeaderGraphID  g.Name()
  HeaderActorID  actorKeyFrom(ctx)
  HeaderNodeID   se.NodeID / nodeID  (node-scoped events only)

Helpers exposed for downstream subscribers:

  PatternRun(runID)       graph.run.<runID>.>
  PatternAllRuns()        graph.run.>
  PatternRunNodes(runID)  graph.run.<runID>.node.>

Behaviour changes:

  * EventBus type on Runner / WithEventBus / WithRunnerEventBus is now
    event.Bus (was event.LegacyEventBus). Source-level breaking change
    for any caller that named the type explicitly; constructor usage
    (NewLegacyMemoryBus → NewMemoryBus) needs adapting.

  * Default bus is event.NoopBus{} (was event.LegacyNoopBus{}); behaves
    identically (drops everything).

  * Node error envelope payload is now {"error": "<msg>"} instead of the
    bare string err.Error(). Required because Envelope.Payload is
    json.RawMessage and json-encoding a bare string produces a quoted
    JSON string literal — the structured map is what most subscribers
    actually want and is easier to extend later (e.g. adding error code
    fields). Documented for the MIGRATION note.

Tests:

  subjects_test.go              new — verifies the convention strings,
                                Pattern.Validate / Matches behaviour,
                                and the sanitiseID escaping (including
                                the dot-in-runID edge case).

  executor_test.go              TestLocalExecutor_EventBus_Integration
                                rewritten to subscribe via PatternRun
                                and assert subject + headers.

  runner_test.go                TestRunner_WithEventBus rewritten the
                                same way; non-blocking default-case
                                drain replaced with a timeout loop
                                because graph.end is published from a
                                different goroutine in the new code path.

All sdk packages pass `go test -race -count=1`.

Made-with: Cursor
Switches every Publish call site in the kanban package from the
deprecated LegacyEventBus / Event API to the new Bus / Envelope API
landed in commit 2 of this PR.

Subject convention used by this package (sdk-internal; no relation to
any flowcraft business schema):

  kanban.card.<cardID>.task.submitted
  kanban.card.<cardID>.task.claimed
  kanban.card.<cardID>.task.completed
  kanban.card.<cardID>.task.failed
  kanban.card.<cardID>.callback.start
  kanban.card.<cardID>.callback.done
  kanban.cron.<scheduleID>.rule.created
  kanban.cron.<scheduleID>.rule.fired
  kanban.cron.<scheduleID>.rule.disabled

Choice of partition key:

  * card_id for task / callback events: every payload already carries
    CardID as its first field, and consumers overwhelmingly want
    "tell me everything that happened to this card";
  * schedule_id for cron events: cron rules outlive any single card
    they spawn, and the natural subscription scope is the rule itself.

Identifier safety: cardID and scheduleID are passed through sanitiseID,
which substitutes '_' for any '.', '*', '>' bytes so user-supplied IDs
cannot fragment the Subject or accidentally turn into a wildcard. Empty
IDs become "_" to keep the segment count stable.

Header propagation on every emitted Envelope (replaces the flat fields
on the legacy Event struct):

  HeaderKanbanKind  EventKind constant ("kanban.task.submitted", ...)
  HeaderCardID      cardID    (task / callback events only)
  HeaderScheduleID  scheduleID (cron events only)
  Source            board.ScopeID()

Subscribers using a broad pattern (kanban.>) can now route on the
kanban_kind header instead of re-parsing the subject.

Helpers exposed for downstream subscribers:

  PatternCard(cardID)        kanban.card.<cardID>.>
  PatternAllCards()          kanban.card.>
  PatternCronRule(schedID)   kanban.cron.<schedID>.>
  PatternAllCron()           kanban.cron.>
  PatternAll()               kanban.>

Behaviour changes:

  * Board.Bus() / Kanban.Bus() now return event.Bus (was LegacyEventBus).
    Source-level breaking change for any caller that named the type
    explicitly; constructor usage (NewLegacyMemoryBus -> NewMemoryBus)
    needs adapting.

  * WithEventBus(event.Bus) Option is still a no-op; signature updated to
    keep source compatibility with the new Bus type. Will be removed in
    v0.2.0 together with the deprecated bridge.

  * The internal eventEnvelope() helper is gone; Publish call sites now
    go through publishCardEvent / publishCronEvent, which build an
    Envelope, set the well-known headers, and dispatch through Bus.
    stampVersion() retains the previous "auto-fill payload Version=N"
    behaviour and is exercised directly by TestStampVersion_*.

  * CallbackStartPayload / CallbackDonePayload are now also stamped by
    stampVersion (oversight in the previous code that the new test
    coverage caught).

Tests:

  subjects_test.go     new   asserts every subject string, validates
                             each Pattern, exercises sanitiseID
                             (including the dot-in-cardID edge case).

  events_test.go       rewritten — kindOf(env) replaces the old
                       string(ev.Type) check; payload assertions go
                       through env.Decode(&p); a new
                       TestBus_SubjectAndHeadersForTaskSubmitted pins
                       the subject + headers contract; a new
                       TestBus_PatternCardScopesToSingleCard verifies
                       two concurrent submits land on distinct subjects.

  helpers_test.go      drainEvents/subscribeBus updated to event.Subscription
                       and PatternAll(); subscribeBus takes a generous
                       buffer (1024) so backpressure never trims a test.

  board_test.go        Bus_Publishes/FanOuts updated to construct
                       Envelopes via event.NewEnvelope.

  kanban_test.go       TestKanban_WithEventBus_DeprecatedNoOp updated
                       to event.NewMemoryBus + Pattern(">").

  scheduler_test.go    Cron rule tests updated to env.Decode + kindOf.

  board_concurrency_test.go  Subscribe call updated to Pattern(">").

All sdk packages pass `go test -race -count=1`.

Made-with: Cursor
Raises sdk/event package coverage from 84.8% to 93.3%. Pure test
addition — no production code touched.

What was missing before:

  observer.go  noopObserver / String() helpers were entirely
               uncovered (0% on every method) because no test ever
               exercised the no-observer fallback shape directly.

  memory.go    NoopBus + noopSubscription had 0% across Publish,
               Subscribe, Close, ID, C — the documented "drop
               everything" Bus implementation was unverified.

               MemoryBus.Publish was at 80.6%; the missing branches
               were the auto-fill of envelope ID/Time, the post-Close
               fast-fail path, and the DropOldest retry-after-evict
               path.

               *memSub.ID was 0% because no existing test reached
               for sub.ID() directly.

  envelope.go  NewEnvelope was at 68.8% — the []byte payload branch,
               the typed-nil ([]byte and json.RawMessage) branches,
               the json.Marshal failure branch, and Decode's error-
               wrapping branch had no coverage.

  subject.go   Pattern.Matches was at 83.3% — the documented "if a
               malformed '>' slips through Validate, treat it as a
               literal" safety net branch was not exercised.

New test files:

  observer_test.go         BackpressurePolicy / DropReason / noopObserver
  noop_test.go             NoopBus end-to-end + Bus interface assertion
  coverage_extra_test.go   MemoryBus.Publish edge paths,
                           Subscription.ID stability + uniqueness,
                           Pattern.Matches malformed-trail fallback

Plus six new cases appended to envelope_test.go for the byte-slice,
nil-typed, marshal-failure, decode-failure, and zero-Headers paths.

Remaining gaps (all unreachable from tests by design):

  fireObserver  87.5% — the `if b.observer == nil` early return is
                dead code: NewMemoryBus seeds observer with
                noopObserver{} so the field is never nil. Left in
                place as a defensive guard.

  Publish       87.1% — the second `if b.closed` check between the
                two RLocks is a narrow concurrency window that can
                only fire if Bus.Close races with a Publish goroutine
                between the inflight registration and the scan
                RLock; reproducing this deterministically would
                require injecting a sync point into the production
                code path.

  noopObserver method bodies still report 0% in cover output despite
  being called from TestNoopObserver_Methods. This is a known go
  cover quirk for single-statement empty methods that the compiler
  inlines; the methods are exercised, just not credited.

All sdk packages still pass `go test -race -count=1`.

Made-with: Cursor
File layout:
  - bus.go now holds only the Bus / Subscription / SubOption contract
    plus NoopBus. Memory.go keeps strictly the in-process implementation.
  - All v0.1.x deprecated symbols (Event / EventType / EventFilter and
    every Legacy* type) move to a dedicated deprecated.go so v0.2.0
    removal is a single `git rm sdk/event/deprecated*.go`. The matching
    bus_test.go is renamed to deprecated_test.go for the same reason.
  - Per-symbol Migration godoc snippets replace the single MIGRATION.md
    referenced by the original plan.

Route cache (memory.go):
  - memSub now stores its pattern pre-split (patternSegs); the Publish
    hot path no longer pays strings.Split per (subscription, publish).
  - matchSegs is the pure segment matcher shared by Pattern.Matches and
    the bus, so the public API behaviour is unchanged.
  - MemoryBus memoises subject->[]*memSub via a routeCache guarded by a
    secondary RWMutex (lock order: b.mu -> routeCacheMu). Hot subjects
    skip the O(N) match scan; the cache is wholesale invalidated on
    Subscribe / removeSub / Close so it can never serve stale entries.
  - WithRouteCacheSize(n) lets callers size or disable the cache.
  - Predicate evaluation stays per-publish since predicates may inspect
    the envelope and cannot be safely memoised by Subject alone.

Benchmarks (M2 Max, 1000 subs, hot subject):
  before: 9102 ns/op  32 B/op  1 alloc/op
  after:    55 ns/op   0 B/op  0 allocs/op  (~165x)

Tests:
  - memory_route_cache_test.go covers cache hit, Subscribe / Unsubscribe
    invalidation, predicate-not-cached, LRU cap eviction, disabled cache,
    and a -race goroutine churn scenario.
  - memory_bench_test.go adds the cached/uncached benchmarks above.
  - Observer godoc gains an explicit ordering note: OnDeliver / OnDrop
    order within one Publish is implementation-defined; the cache makes
    that order stable per Subject, but observers must not depend on it.

Drive-by: `go fmt` reformatted a godoc block in sdk/kanban/events.go.
Made-with: Cursor
@lIang70
lIang70 merged commit d2bf493 into main Apr 23, 2026
8 checks passed
@lIang70
lIang70 deleted the refactor/sdk-event-bus branch April 23, 2026 09:26
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