Lesson 7 · Publishing, consuming & delivery · the flagship
Subscribing & the handler
Between the wire and your code sits one function — and it does the cleverest thing in the whole layer: it makes an async event feel exactly like a synchronous, tenant-scoped request.
Your win: trace a message through handleMsg step by step, and explain
how tenancy and tracing are rebuilt on the consumer so RLS survives the async hop.
Your handler, and the wrapper around it
Your subscriber gives the golib a callback with this signature:
type MsgHandler func(ctx context.Context, data []byte) (retry bool, err error)
You get a ctx and the payload bytes; you return an error and a retry
bool that decides what happens on failure (Lesson 8). But you never see the raw NATS message — the
golib's handleMsg wraps your callback and does the plumbing first.
handleMsg — the seven steps
This is the heart of every subscription. Read it top to bottom; each numbered step is a real thing the golib does before (and after) your handler runs:
ctx, cancel := context.WithTimeout(context.Background(), 360*time.Second) // ① a fresh ctx (no request ctx exists)
msgID := msg.Header.Get("Nats-Msg-Id") // ② for logging + metrics (deferred)
if skipMsgOlderThan != nil && msg too old { return } // ③ stale-message guard (DENY_CONSUME_OLD_MESSAGE)
var dataInMsg npb.DataInMessage
if err := proto.Unmarshal(msg.Data, &dataInMsg); err != nil {
msg.Ack(); return // ④ can't parse → ACK (drop the poison)
}
if dataInMsg.TraceInfo != nil {
ctx = ContextWithTraceInfo(ctx, dataInMsg.TraceInfo) // ⑤a rehydrate the trace…
ctx, span = interceptors.StartSpan(ctx, spanName, SpanKindConsumer) // …and start a consumer span
}
ctx = interceptors.ContextWithJWTClaims(ctx, &CustomClaims{ // ⑤b ← THE FLAGSHIP
Manabie: &ManabieClaims{ ResourcePath: dataInMsg.ResourcePath, UserID: dataInMsg.UserId }})
retry, err := cb(ctx, dataInMsg.Payload) // ⑥ YOUR handler runs, with a full ctx
if err != nil { if !retry { msg.Ack() }; return } // ⑦ ack/retry/drop (Lesson 8)
msg.Ack()
⑤b is the whole point: tenancy survives the hop
resource_path) into the
envelope. Here, handleMsg takes it back out and calls
ContextWithJWTClaims — the exact same function the auth
interceptor uses on a synchronous request (Auth course, Lesson 4). So by the time your handler runs,
ResourcePathFromContext(ctx) returns the right tenant, and any DB query the handler
makes gets the correct set_config('permission.resource_path') — Row-Level
Security works exactly as it would on a live request.
This is the single cleverest thing in the repo's NATS layer. An event is processed seconds or minutes after the request that caused it, on a different pod, with no request context of its own — yet tenant isolation and distributed tracing both hold, because they were smuggled through the message and re-injected here. The publish/consume symmetry (envelope in → context out) is what makes async eventing safe in a multi-tenant system.
handleMsg through one of three: Subscribe
(push, no queue group), QueueSubscribe (push queue-group — the common
path, Lesson 4), and PullSubscribe (pull batches — only the
activity-log consumer, Lesson 11). All force AckExplicit; the queue/pull variants
provision the consumer (UpsertConsumer) first. Different subscription shapes, same
per-message plumbing.
Consuming & acknowledging
How a subscriber receives and acks messages — the mechanics handleMsg wraps.
→ docs.nats.io — consuming messages
→ in-repo internal/golibs/nats/jetstream.go:663-740 ·
the Auth course's ContextWithJWTClaims (RLS)
Check yourself (from memory)
Q1. How does a NATS subscriber's DB query stay tenant-scoped (RLS)?
handleMsg rebuilds the
JWT claims on ctx, so RLS works just like a synchronous request.
Q2. What does the MsgHandler's retry return value control?
retry=true → don't ack → redeliver;
retry=false → ack → drop. (Full logic in Lesson 8.)
Q3. What happens if handleMsg can't unmarshal the envelope?
PROTO_UNMARSHAL_ERROR metric.
handleMsg — the seven steps + the flagship.func(ctx, []byte) (retry bool, err error).
handleMsg wraps it: ① fresh ctx (360s timeout) → ② read
Nats-Msg-Id + defer metric → ③ SkipMsgOlderThan? skip → ④
proto.Unmarshal DataInMessage (fail → Ack, drop poison) → ⑤a
rehydrate trace from TraceInfo → consumer span → ⑤b re-inject tenancy:
ctx = ContextWithJWTClaims(ResourcePath, UserID) (the same fn the auth
interceptor uses → RLS survives the async hop) → ⑥ cb(ctx, payload) →
⑦ success Ack; err+retry no-ack (redeliver); err+!retry
Ack (drop). Three entries: Subscribe/QueueSubscribe
(push)/PullSubscribe (pull, activity-log only) — all force AckExplicit.
Publish-in-envelope + consume-out-to-ctx = the symmetry that makes async multi-tenant eventing safe.resource_path and the trace context into the message envelope at publish time. On the
consumer, before the handler runs, we unpack them and re-inject them into the context using the
same claims function the synchronous auth interceptor uses — so Row-Level Security and distributed
tracing behave exactly as they would on a live request, even though the event is processed later, on
another pod."
Nak/Term, no DLQ). Ask me to walk the
seven steps again — this is the lesson worth over-learning.
1. In-repo: internal/golibs/nats/jetstream.go:271,663-740. docs.nats.io — consuming.