安装
npx skillscat add munyon-canyon/monaco/nats-consumer 通过 SkillsCat registry 安装。
生成NATS JetStream消费者代码,处理跨模块事件,支持幂等投递和事务处理。
NATS consumer
A module reacts to an event through a consumer that it owns. Cross-module effects always travel as an event. A consumer never calls another module's code. Flow 00's system.echo handler is the worked example.
The pieces
| Piece | Path | What it does |
|---|---|---|
| Outbox append | apps/backend/internal/platform/db/events.go |
tx.Events.Append(ctx, ev) writes the event row inside the caller's uow.Do. |
| Relay | apps/backend/internal/platform/bus/relay.go |
Drains unpublished rows and publishes each with Nats-Msg-Id set to the event id. Both api and worker run one. |
| Publish | apps/backend/internal/platform/bus/publish.go |
Conn.Publish sets the message id through jetstream.WithMsgID. The EVENTS stream drops duplicates inside its 2 minute window. |
| Dispatch | apps/backend/internal/platform/bus/dispatch.go |
Registry.Dispatch decodes, runs each handler in its own transaction, and makes one ack, nak or term call. |
| Registration | apps/backend/internal/platform/bus/registry.go |
bus.Consumer{Durable, Handlers} and bus.Handle(name, fn). Durable and handler names are unique. A duplicate panics at boot. |
| Dedupe table | apps/backend/queries/platform/events.sql |
InsertDelivery writes (handler, event_id) into event_deliveries with ON CONFLICT DO NOTHING. |
| Chaos suite | apps/backend/internal/testkit/consumer.go |
testkit.ConsumerSuite replays events under redelivery, reordering and crashes, and compares the end state with a clean run. |
Worked example: flow 00
apps/backend/internal/events/system.godefinesSystemPinged.apps/backend/internal/modules/system/app/record_ping.goappends it insideuow.Do.apps/backend/internal/modules/system/module.godeclares the consumer: durablesystem_echo, one handlersystem.echo.apps/backend/internal/modules/system/adapters/echo.gohandles it with a guarded update and publishes a live hint throughtx.AfterCommit.apps/backend/internal/modules/system/echo_test.gotests the handler, andapps/backend/internal/modules/system/echo_chaos_test.goruns the chaos suite.
The Dispatch contract
A handler has this shape:
func (h Echo) Handle(ctx context.Context, tx db.Tx, e events.SystemPinged, at time.Time) erroratis the delivery time. Dispatch stamps it onevent_deliveries.handled_at. Use it for any time the handler stores.- Dispatch opens the transaction. It first inserts the
(handler, event_id)row intoevent_deliveries. When the row already exists, the event was handled, so Dispatch commits and acks without calling the handler. - The handler runs inside that same transaction. Its writes and the delivery row commit together, or neither does.
- The handler writes through
tx. It appends follow-on events withtx.Events.Append. Side effects that must wait for the commit, such as live hints, go intx.AfterCommit. - The handler returns
nilto ack. - It returns an
errserror to nak or term.errs.VerdictForinapps/backend/internal/errs/errs.godecides:- A retryable code naks with backoff (1 s, 5 s, 30 s, 2 min, 10 min). The retryable codes are marked
Retryable: trueinapps/backend/internal/errs/codes_*.go: upstream, Jupiter, Privy, RPC and database unavailability. - Any other code terms the message and publishes a dead letter. A plain Go error counts as
internal, and a panic counts aspanic. Both term.
- A retryable code naks with backoff (1 s, 5 s, 30 s, 2 min, 10 min). The retryable codes are marked
- With several handlers on one message, any nak wins, then any term, then ack.
- A message gets 10 deliveries (
MaxDeliver) before JetStream gives up.
So a handler must be safe to run twice for the same event after a nak. Make every write converge. Use ON CONFLICT DO NOTHING for inserts and a guarded UPDATE ... WHERE <expected state> for state changes, as EchoPing does in apps/backend/queries/system/pings.sql.
Long work
Work that can outlast the ack wait calls stop := bus.KeepAlive(ctx) and defer stop(). It sends InProgress every 10 s. Outside Dispatch it does nothing. Never hold a database transaction open across a slow outbound call. Split the work so the slow call happens before or after the transaction.
One consumer per module
- A module declares its consumers in
Consumers()in itsmodule.go.apps/backend/cmd/worker/boot.gostarts every module's consumers. - Each consumer is a durable on the shared
EVENTSstream, filtered to its handlers' event types. A module never creates a stream. The streams live inapps/backend/internal/platform/bus/streams.go. - The durable name is
<module>_<name>and the handler name is<module>.<name>. The handler name is theevent_deliverieskey and the name in theconsumerscell of the flow's file,packages/flows/backend/<id>.tsv.
Add a consumer
- Run
just gen consumer <module> <name>. It writes the adapter and a test, appends the consumer toConsumers(), and switchesmain_test.gototestkit.Main(m, testkit.WithNATS()). - Replace the placeholder event type in the adapter with the event you consume.
- Write the handler to the contract above.
- Replace the generated test's
t.Fatal("not implemented")with a test that dispatches the event and asserts the end state. - Add a chaos test with
testkit.ConsumerSuite, built with//go:build faultpoints, likeecho_chaos_test.go. - Add the handler name to the
consumerscell of every flow that emits the event.
Add an event
- Add the type to
apps/backend/internal/events/<aggregate>.gowithType,AggregateTypeandAggregateIDmethods and aVfield. - Register it in
<module>Registrations()inapps/backend/internal/events/<module>_registrations.go, the file of the module that publishes it. Leaveregistry.goalone. A new module adds its file and one line toregistrations()there. - Write its golden payload
apps/backend/internal/events/testdata/golden/<type>.v1.json, either by hand or withgo test -short ./internal/events -updatefollowed by filling in real values.-updatewrites a golden holding the zero value at the currentvfor a registered event that has none and never rewrites an existing one.TestGoldenPayloadsrequires the golden to round-trip byte for byte through the Go type, and the registry tests read their expected types from the golden files. - Regenerate
docs/reference/events.mdwithgo generate ./...fromapps/backend.
Checklist
- The handler is idempotent under redelivery.
- Retryable failures return a retryable
errscode. Everything else terms. - No outbound call runs inside the transaction.
- Post-commit side effects use
tx.AfterCommit. - The chaos test converges.
- The file of each flow that reaches the handler names it in
consumers. - A consumer that rebuilds a derived table joins
projectionDurablesinapps/backend/cmd/monacoctl/replay.go, somonacoctl replayruns it.