Deepen Publishing and Redelivery APIs

I've oriented myself. Here's where things stand while I finish gathering the reference contracts.
What Phase 2 needs (from COR-984): deepen natsmsg.Publisher and backoff.Policy so they're complete enough to replace the proven production publishers/redelivery loops in Phase 3 — without widening what the shared package owns.
Key facts I've established:
natsmsg.Publisherandbackoff.Policyare not yet consumed anywhere in production (entiredb/entire-api/mirror-pipeline only use thenatsmsgtrace-context + DLQ helpers ongo-nuts v0.7.0). So I can reshape those two APIs cleanly; the constraint is matching the production envelopes exactly, not source-compat.- The central gap is adapters:
Publishertoday only speaks the modernjetstream.JetStream, but mirror-pipeline'snatspub(the primary publishing reference) speaks the legacynats.JetStreamContext(PublishMsg(m, opts...)+nats.Context). Same split on the redelivery side —backoffonly drivesjetstream.Msg, but the legacy*nats.Msghas variadic-optNak/Term/Metadatasignatures that don't fit the same interface. - mirror-pipeline's
natspubalso proves the need for caller-selected operation names (SpanName: "github_meta.publish") and amessaging.operation.type=publishattribute that go-nuts' Publisher doesn't set yet.
I've dispatched three parallel surveys (entiredb, entire-api, mirror-pipeline callers) to pin the exact delay sequences, disposition matrices, span names, and JetStream client types each production caller uses — so the shared API preserves them precisely. Baseline build/tests are green, and I'm on branch nodo/cor-984-....
Once the surveys return I'll bring you a concrete API-shape proposal (how the legacy/modern adapters are exposed) before writing the implementation, since that shape is what Phase 3 will build against.
natspub callers in mirror-pipeline — findings for the shared Publisher API
The shared core being mirrored
internal/natspub/natspub.go defines the contract every caller uses. Key facts (confirming what you read):
- Legacy JetStream only. The publisher interface is the classic
nats.JetStreamContextshape, andPublishcallsPublishMsgwith anats.Contexttimeout. No modernjetstream.JetStreamanywhere on the publish path (see section 3).natspub.go:37-40—type JetStreamPublisher interface { PublishMsg(m *nats.Msg, opts ...nats.PubOpt) (*nats.PubAck, error) }natspub.go:109-111—pubCtx, cancel := context.WithTimeout(ctx, AckWait); ... ack, err := js.PublishMsg(msg, nats.Context(pubCtx))
natspub.go:35—const AckWait = 5 * time.Secondnatspub.go:26—const MsgIDHeader = "Nats-Msg-Id"- The
Publicationstruct (natspub.go:44-68) has exactly the fields you listed:Tracer,SpanName,ErrKind,LogMsg,Subject,MsgID,Marshal func() ([]byte, error),SpanAttrs []attribute.KeyValue,LogAttrs []slog.Attr. Publish(natspub.go:80-131) prepends a fixed messaging.* triplet toSpanAttrs, starts aSpanKindProducerspan onotel.Tracer(pub.Tracer), runsMarshalinside the span, sets theNats-Msg-Idheader, callsotelnats.Inject(ctx, msg), publishes, then stampsmessaging.nats.sequence/messaging.nats.duplicateand logs one INFO line.- Helper
natspub.PlacementAttrs(jurisdiction, cluster)(natspub.go:72-77) returnsentire.target_jurisdiction+entire.target_cluster_id.
1. The callers of natspub.Publish / natspub.Publication
There are six call sites of natspub.Publish, all constructing a natspub.Publication literal. Each typed publisher is a thin Publisher{ js JetStreamPublisher } wrapper (JetStreamPublisher = natspub.JetStreamPublisher, NatsMsgIDHeader = natspub.MsgIDHeader), delegating mechanics to natspub and owning only the domain parts.
| Package (file:line) | Tracer | SpanName (caller-selected op name) | ErrKind | LogMsg |
|---|---|---|---|---|
pkg/metajobs/publisher.go:31 | "metajobs" | "github_meta.publish" | "meta job" | "Published github_meta job" |
pkg/prjobs/publisher.go:31 | "prjobs" | "github_pr.publish" | "pr job" | "Published github_pr job" |
pkg/pushauthorjobs/publisher.go:34 | "core-fanout" | "github_push_author.publish" | "push-author job" | "Published github_push_author job" |
pkg/donejobs/publisher.go:31 | "donejobs" | "mirror.done.publish" | "done job" | "Published mirror completion" |
internal/mirrorjobs/publisher.go:40 | "mirrorjobs" | "mirror.git.publish" | "git mirror job" | "Published git mirror job" |
pkg/webhookevents/publisher.go:89 | "webhookevents" | "webhook.publish" | "forwarded webhook" | "Published forwarded webhook" |
Note two things the shared API must accommodate:
- Tracer name is not always the package name.
pushauthorjobssetsTracer: "core-fanout"(the binary/scope name), while itsSpanNameis"github_push_author.publish". Tracer and SpanName are fully independent, caller-chosen strings. - SpanName conventions vary: dotted domain names like
github_meta.publish,mirror.done.publish,mirror.git.publish,webhook.publish. All are literal strings passed straight tootel.Tracer(Tracer).Start(ctx, SpanName, ...).
Representative construction details
metajobs (pkg/metajobs/publisher.go:30-42) — the canonical placement-scoped shape:
- Subject (
pkg/metajobs/jobs.go:62-64):SubjectPrefix + jurisdiction + "." + cluster. - MsgID (
jobs.go:115-117):strconv.FormatInt(githubRepoID,10) + "/" + observedAt.UTC().Format(time.RFC3339Nano)— stable across redeliveries so republishes collapse in the dedup window. - Marshal: closure over
Marshal(job)which stamps schema version, validates, thenjson.Marshal. - SpanAttrs:
PlacementAttrs(...)appended withjob.SpanAttrs()(jobs.go:171-177:entire.target_ulid,entire.github_repo_id,entire.source_repo). - LogAttrs (
jobs.go:180-186): unprefixed counterpartstarget_ulid,github_repo_id,source_repo.
pushauthorjobs (pkg/pushauthorjobs/publisher.go:33-52) — shows a caller building SpanAttrs/LogAttrs inline rather than from job methods, and mixing attribute types:
Note the attribute value types used across callers: attribute.String, attribute.Int64, attribute.Int (the shared API's SpanAttrs []attribute.KeyValue already covers all of these).
mirrorjobs (internal/mirrorjobs/publisher.go:30-51) — most complex Subject/MsgID derivation; builds a gitjobs.ParsedSubject and composes MsgID inline with fmt.Sprintf:
Takeaway: not every caller uses PlacementAttrs; SpanAttrs is an arbitrary caller-assembled slice.
webhookevents (pkg/webhookevents/publisher.go:81-106) — the one caller that wraps natspub.Publish with its own metrics (counter + latency histogram, recorded in a defer), and builds attrs fully inline (no placement, no job methods):
Subject(f.Event) and MsgID(f.DeliveryID) (events.go:114, :141). This one is worth noting for the shared API: the caller layers outcome/duration metrics around the shared publish call, classifying the returned error (classifyPublishError → stream_full/other). If the shared Publisher is to support this, it must return the raw publish error unchanged (natspub does: fmt.Errorf("publish %s: %w", ...) preserves the NATS error for strings.Contains(err.Error(), "maximum bytes exceeded")).
donejobs (pkg/donejobs/publisher.go:30-42) mirrors metajobs exactly, with MsgID(job.RepoULID, job.Status) and SpanName: "mirror.done.publish".
Summary of what Publication must express (confirmed by real callers)
- Independent, free-form
TracerandSpanNamestrings (tracer ≠ package name inpushauthorjobs; span names use varied dotted conventions). ErrKindandLogMsgas free strings.Subjectfully resolved caller-side (placement concatenation, orgitjobs.Subject(parsed), orSubject(event)).MsgIDderived caller-side by many strategies: numeric-id/timestamp,a/bconcatenation, 5-partfmt.Sprintf, or a bare delivery id.Marshalas a closure (may stamp version + validate before JSON, or plainjson.Marshal).SpanAttrsas an arbitrary[]attribute.KeyValue(String/Int64/Int seen), sometimesPlacementAttrs(...)-based, sometimes subject-derived, sometimes fully inline.LogAttrsas arbitrary[]slog.Attr.- The helper
PlacementAttrs(jurisdiction, cluster)is shared by the four placement-scoped publishers; the other two don't use it. Worth carrying into the shared API as an optional helper, not a required field.
2. pkg/otelnats vs go-nuts natsmsg
mirror-pipeline pkg/otelnats/nats.go (otelnats.Inject, lines 39-44):
It adapts nats.Header to propagation.TextMapCarrier via HeaderCarrier (Get/Set/Keys). Extract (lines 48-53) does the mirror. This is the only otelnats helper on the publish path — natspub.Publish calls otelnats.Inject(ctx, msg) at natspub.go:106, right after setting the Nats-Msg-Id header. No other otelnats helper is used when publishing (Extract is consumer-side).
go-nuts natsmsg/tracecontext.go (Inject, lines 58-63) is byte-for-byte the same body — identical HeaderCarrier Get/Set/Keys and the same Inject implementation. The go-nuts package is the superset/unification (its doc at tracecontext.go:1-10 says it unifies entire-core's internal/natsmsg and entire-api's internal/otelnats, "itself ported from mirror-pipeline"; tracking COR-929). Differences that matter for the shared API:
natsmsg.HeaderCarrieradditionally implementspropagation.ValuesGetterviaValues(key)(tracecontext.go:38-41, asserted at:51-54). The comment explains why: without it the baggage propagator falls back toGetand reads only the first of multiple same-named headers, truncating multi-header W3C baggage. mirror-pipeline'sotelnats.HeaderCarrierhas noValuesmethod — so it has that latent baggage-truncation bug the go-nuts version fixes.natsmsg.Extractdelegates toExtractHeader(ctx, msg.Header)and addsExtractHeader(ctx, h nats.Header)(:67-79) so it works for both*nats.Msg(msg.Header) and modernjetstream.Msg(msg.Headers()). mirror-pipeline'sotelnats.Extractonly takes*nats.Msg.- natsmsg bundles extras not in otelnats:
StartConsumerSpan(...)(:92-99, collapses Extract+Start with standard messaging attrs) andClampToInt64(:103-108, saturating uint64→int64 for sequence/delivery-count attributes). These are consumer-side conveniences; the publish path only needsInject.
Net: for the publish API, go-nuts natsmsg.Inject is a drop-in replacement for otelnats.Inject (same behavior) and strictly better on the carrier (ValuesGetter). The shared publisher should call natsmsg.Inject.
3. Legacy vs modern JetStream, and consumer-side redelivery/backoff
Publish path is legacy nats.JetStreamContext exclusively. grep for the modern API import (github.com/nats-io/nats.go/jetstream) across the whole repo (excluding vendor) returns nothing. The only occurrence of the token jetstream. is a comment in pkg/fanoutengine/engine.go:12 ("The modern jetstream.Consume shape lands when this graduates to the standalone module"). Every publish uses PublishMsg(m *nats.Msg, opts ...nats.PubOpt) (*nats.PubAck, error):
natspub.go:39,111(the shared core)- Direct
PublishMsgcallers outside natspub:pkg/webhookdlq/publisher.go:127,cmd/rekicker/rekick.go:63,223,cmd/meta-fanout/{lifecycle.go:156,backfill.go:97,backfill_prs.go:85},cmd/mirror-pipeline-admin/{rekick.go:109,dlq.go:387}. All legacy. webhookdlqand themeta-fanoutbackfill/lifecycle publishers reusenatspub.AckWait/natspub.MsgIDHeaderbut hand-roll their ownPublishMsg+ span rather than going throughnatspub.Publish(a sign the shared API may want to cover a few more shapes, but they're still legacyPublishMsg).
Consumer-side redelivery/backoff = pkg/fanoutengine/engine.go (this is the COR-762 work). The engine is explicitly kept "on the current legacy nats.JetStreamContext API" (engine.go:11-13); it holds a js nats.JetStreamContext (:216) and subscribes via js.PullSubscribe(...) (:299).
Two distinct backoffs:
- Subscribe-retry backoff (
engine.go:40-45,:307-313):backoffInit = 1s,backoffMax = 30s, doublingbackoff = min(backoff*2, backoffMax)between failedPullSubscribeattempts. - Per-message redelivery via
NakWithDelay(c.cfg.NakDelay)— a flat delay (all three fanout binaries setNakDelay = consumerFailureDelay = 30s,MaxDeliver = 8= first attempt + 7 retries,AckWait = 30s;cmd/fanout/consumer.go:25,30,35,cmd/meta-fanout/consumer.go,cmd/core-fanout/consumer.go).
The Nak/Term decision tree lives in nakOrExhaust (engine.go:605-640) and the panic path handlePanic (:667-697):
Term()immediate, no redelivery, for permanent errors: bad envelope (engine.go:483-487,ReasonBadEnvelope) and*InputErrorfrom the resolver (:511-526,ReasonInputErroror a caller sub-reason likerepo_gone).isFinalDelivery(:717-722):meta.NumDelivered >= uint64(maxDeliver).TermOnExhaustion(Config field,:193-198) is the COR-762 fix: on a work-queue stream a Nak on the final delivery leaves the message un-acked and orphaned untilmax_age, so instead the engineTerm()s on exhaustion (reasonmax_deliveries) even without a DLQ. In this repo it's enabled only on the meta-fanout backfill consumers (cmd/meta-fanout/backfill_consumer.go:267,backfill_prs_consumer.go:246, both withNakDelay: consumerFailureDelay); the webhook/durable fanout consumers leave it off (plain Nak, or Term + DLQ capture). The panic path honors it too (engine.go:685-692: Term the poison-pill on final delivery to drain the queue).- Fan-out publish aggregation:
dispatchRoutespublishes all routes, then Naks if any failed (:568-579), with an in-process route memo so already-PubAck'd regions are skipped on redelivery (:537-556) — becausemirror_git_v1's 1s server-side dedup window does not cover the ~30s redelivery (:498-507).
The AckableMsg interface the engine narrows to (engine.go:203-209) is the concrete signature surface for the Ack/Nak/Term/delay contract: Ack, Nak, NakWithDelay(delay time.Duration, ...), Term, Metadata() (*nats.MsgMetadata, error) — all legacy nats.AckOpt-based.
Bottom line for the shared API design
- The shared
Publisher/Publicationmust keepTracerandSpanNameas independent free strings (pushauthorjobs proves they diverge) andSpanAttrs/LogAttrsas fully caller-assembled slices (mirrorjobs and webhookevents don't usePlacementAttrs). All six caller-selected operation names above are just literalSpanNamevalues — the current struct already expresses every one. - It must return the underlying publish error unwrapped-enough for callers like webhookevents to string-match (
%wwrapping is sufficient). - Swap
otelnats.Inject→ go-nutsnatsmsg.Inject(identical body, plus theValuesGetterbaggage fix). - Keep it on legacy
PublishMsg+nats.Context(timeout)— that's what 100% of the mirror-pipeline publish path uses today; the modernjetstream.JetStreamAPI appears nowhere.</result> <usage><subagent_tokens>68628</subagent_tokens><tool_uses>20</tool_uses><duration_ms>181127</duration_ms></usage>
The user would like to clarify before answering. What questions or context do you have? I'm happy to go deeper on any of the trade-offs — for example the parity-testing implications, how each shape affects the Phase 3 migration of the legacy callers (entiredb repoops/gitjobs publishers, mirror-pipeline's natspub and fanoutengine), the normalized-PubAck decision, or anything else on your mind.
gien my goal is to migrate everything to the modern interface, which approach is most suitable?
Done. Here's the final state.
COR-984 shipped — PR #10 open against main
- Committed on
nodo/cor-984-...(5591f31); the post-commit hook addedEntire-Checkpoint: 2e9096fe3f89automatically. - Pushed and PR #10 opened targeting
mainwith a full summary.
What it delivers — the shared publishing (natsmsg.Publisher) and redelivery (backoff.Policy) seams now speak both the modern jetstream.JetStream/jetstream.Msg API (unchanged — the migration destination) and the legacy nats.JetStreamContext/*nats.Msg API via LegacyPublisher / NakOrTermLegacy bridges. Each modern+legacy pair delegates to a single unexported core, so the two are parity-tested against one behavioral contract and the Legacy* symbols delete cleanly when Phase 3 finishes migrating callers. Production envelopes (repoops 5s→30s, permswebhook configurable ceiling, ingest 1s→300s) are pinned, and the full disposition matrix is covered.
All gates green: go test -race ./..., golangci-lint run (0 issues), go build, go mod tidy -diff, git diff --check.
One step remains, gated on merge: cutting the v0.8.0 tag on main for Phase 3 consumers (COR-985). Go-module convention puts that tag on the merged main commit, so once PR #10 merges, tell me and I'll cut it — or you can. That's the only piece of the acceptance criteria not yet closable from here.
A couple of notes for review, since they were judgment calls:
- The
backoffmetadata-unavailable fallback is now baseNakDelay(entire-api's oldbackoffForreturned a degenerate 5s). The delay envelope is preserved exactly; only that error-path fallback was unified, and it matches what entiredb already did. Called out in the code docs. DLQPublisherbecame a type alias of the newLegacyJetStream(they were structurally identical, andifacelint flagged the duplication) — no behavior change.
two P2s: 1. [P2] Preserve caller-owned span attributes. natsmsg/publish.go:51 exposes no way to add domain attributes to its internally owned producer span. Phase 3 replacements would lose attributes such as placement and job identity currently passed through mirror-pipeline’s natspub (https://github.com/entirehq/mirror-pipeline/blob/878e8eaf3036f58a2e635a1503bdd3c56278abd4/internal/natspub/natspub.go). Wrapping Publish in another span changes span topology and leaves the actual publish span untagged. Add caller-supplied attributes/options forwarded to StartProducerSpan.
- [P2] Don’t store application span names as messaging.operation.name. natsmsg/publish.go:176 records values such as repo.ops.publish in that semantic field. The pinned OTel convention defines it as the system-specific messaging operation—examples include ack, nack, and send (/Users/nodo/go/pkg/mod/go.opentelemetry.io/otel@v1.44.0/semconv/v1.41.0/attribute_group.go:11447). Keep the caller value as the span name, while setting messaging.operation.name to the NATS operation (publish) or omitting it.
Both P2 fixes are committed (ac3f0ac, checkpoint 3087f9932a47) and pushed — PR #10 now includes them.
What changed
P2.1 — caller-owned span attributes preserved. Publisher.Publish and LegacyPublisher.Publish now take a variadic attrs ...attribute.KeyValue, forwarded onto the internally-owned producer span through StartProducerSpan alongside the standard messaging.* set. So a Phase 3 replacement of mirror-pipeline's natspub (or the entiredb publishers) keeps its placement / job-identity tags on the actual publish span — no extra wrapping span, no topology change. The signature stays backward-compatible (existing zero-arg calls still compile).
P2.2 — messaging.operation.name no longer misused. I confirmed against the pinned semconv (v1.41.0): operation.name is the system-specific op (send/ack/nack), and the operation.type enum is create/send/receive/process/settle. The production implementations being replaced set operation.type="publish" (a valid low-cardinality custom value) and no operation.name. So I dropped operation.name entirely — the caller's Operation value is now the span name only, and operation.type=publish continues to carry the operation. This matches the production spans exactly (no added/changed attribute that would break existing dashboards) and stops leaking the app span name into the wrong semantic field.
The parity test now forwards domain attrs and asserts they land on the span while messaging.operation.name stays unset. All gates green again: go test -race ./..., golangci-lint (0 issues), gofmt, go mod tidy -diff, git diff --check.
The v0.8.0 tag on main after merge remains the only open item.