Service Bootstrap Phases: Wiring/Readiness vs. Post-Readiness Activities
Table of Contents
Summary
Every ORE Studio NATS service bootstraps in two phases. Phase 1 —
Wiring & Readiness: connect NATS, build the JWT signer, and register
every subject handler, including the service's own. Phase 2 —
Post-Readiness Activities: do anything that needs to call a
subject — mint a service token, warm a cache, run a startup query
against another service — including calling the service's own
subjects. The two phases are separated because NATS subscriptions
dispatch on the NATS client library's own thread, independent of the
application's io_ctx, so a subject is answerable the instant it is
subscribed, not when the registration function eventually returns.
Getting the order backwards — calling a subject before it is
subscribed — doesn't hang the process (the client-credentials
provider already retries with backoff for exactly this race), but it
is needless, confusing failure-and-retry noise on every clean start,
and for a genuinely self-referential call (a service authenticating
against itself) it can look identical to a deadlock in the logs.
This doc names the two phases so future services get the order right
the first time, worked through end-to-end via IAM's party_cache.
Detail
Why two phases, and why ordering is safe to reason about
A NATS subscription's callback runs on the NATS client library's own
dispatch thread (see the IMPORTANT comment in
ores.nats/src/service/client.cpp's on_msg), not on the
application's io_ctx. nats.subscribe(...)=/=queue_subscribe(...)
makes a subject live the moment it is called — independent of whether
the surrounding register_handlers() function has returned, and
independent of whether the calling coroutine has yielded control back
to io_ctx. Likewise, request_sync is a genuinely blocking call
into the underlying cnats library (natsConnection_RequestMsg), not
an io_ctx-scheduled operation — it does not starve io_ctx of the
chance to process anything, because nothing about serving that request
needs io_ctx in the first place.
That means: the only real constraint is subscribe before you call.
There is no deeper thread/executor deadlock lurking here — but getting
the order backwards still produces avoidable retries (the
client-credentials provider's authenticate() already backs off and
retries "the startup race where [the target] hasn't subscribed its
NATS subjects yet") and, for a same-service self-call, reads in the
logs exactly like a hang until you know this. Put the two kinds of
work in separate, ordered phases and the question never comes up.
Phase 1 — Wiring & Readiness
Everything that makes the service able to answer a request:
- Connect the NATS client (
nats.connect()). - Build the per-service JWT verifier/signer.
- Construct every handler and call
subscribe=/=queue_subscribefor every subject the service owns — via the per-entityregister_<entity>_handlers()registrars (Entity-composed registrars), or directly for hand-written handlers. This includes the service's own subjects it might later call itself (e.g. IAM'siam.v1.auth.service-login).
At the end of Phase 1, every subject is subscribed and will accept a request — from another service, or from itself. That is not quite the same as every subject giving its fully warmed answer: a handler that depends on a Phase-2 activity (e.g. a cache not yet armed/warmed) can already be receiving traffic before that activity completes. See "Known limitation" below — this is a narrow, self-healing window, not a correctness problem, but it is real and worth naming rather than implying Phase 1 alone means "fully ready."
Phase 2 — Post-Readiness Activities
Everything that needs to call a subject, including the service's own:
- Mint a service token via
ores::iam::client::make_service_token_provider— whether calling another service (the common case; see Service-to-Service Auth: Client Credentials vs. On-Behalf-Of Delegation) or, for IAM itself, calling its ownservice-loginsubject. - Warm any nats-event-cache-generated cache (or a hand-written
equivalent) — a bulk read against a subject that, for IAM's
party_cache, now requires that same freshly-minted token. - Any other one-shot startup query against another (or the same) service.
- Finally, the
on_startedhook (heartbeat publisher, etc.) and the "Service ready" log line — seerun_signing=/=domain_service_runnerinores.service.
Worked example: IAM's party_cache
IAM is the sharpest case because it is self-referential: its
party_cache (see Generic entity-mirror cache primitive + codegen
facet) calls refdata's read_parties_for_cache, which — since
that subject started requiring a valid signed JWT — needs a token.
The obvious-looking implementation, "mint the token and construct
party_cache right where party_cache is needed," breaks because
that point in registrar::register_handlers comes before IAM's own
auth_handler (owner of service-login) is constructed later in the
same function. Reordering — registering auth_handler (Phase 1) before
minting the token and warming the cache (Phase 2) — is the whole fix:
Figure 1: IAM registering every subject (Phase 1) before minting its own service token and warming party_cache (Phase 2). Subscriptions dispatch on the NATS client's own thread, so a Phase-1-subscribed subject is answerable from Phase 2 onward without waiting for the registration function to return.
Concretely, inside ores.iam.core's registrar::register_handlers:
- Phase 1: construct
party_cache(token-less — it's needed byauth_handler=/=account_handler=/=bootstrap_handler's constructors), then construct and subscribe every IAM handler (auth_handlerincluded) — in whatever order, as long as all of them precede Phase 2. - Phase 2:
make_service_token_provider(nats, ctx.service_account(), service_password)(safe now —service-loginis live); callparty_cache::set_token_providerto arm it; callwarm_and_subscribe_party_cache(tenant_ids)to do the initial load and subscribe toores.refdata.party_changedfor future reloads.
A non-IAM service calling another service (e.g. ores.reporting
minting a token to call into IAM, or ores.synthetic the same) has no
self-reference to worry about — the target service is a separate,
already-running process, so there is no ordering constraint relative
to its own Phase 1. The two-phase discipline still applies for the
same reason it always does (don't call out before you're ready to be
called back, e.g. for a reply-and-retry loop that itself needs a
subject you haven't subscribed yet), but the sharp, self-referential
failure mode is IAM-specific among today's services.
Known limitation: a narrow, self-healing startup race
Phase 1 subscribes auth_handler (so real login traffic can already
be arriving) before Phase 2 arms party_cache with a token and warms
it. A login landing in that exact window that triggers a cache miss
(auth_ensure_parties_cached) calls party_cache::load() with no
token provider set yet; since read_for_cache now requires a valid
JWT, that particular load is rejected server-side and
party_cache::load() just logs a warning and returns — leaving that
one tenant's partition empty for that request. It is self-healing (the
next cache miss, after Phase 2 completes, succeeds normally) and the
window is only open for the few subscribe calls between auth_handler
and the end of Phase 2 — but it is a real, if narrow, behavioural
change from before this pattern existed (previously read_for_cache
had no auth check, so the same race was silently harmless). Accepted
as-is rather than engineered away, because doing so would mean
delaying real traffic until Phase 2 completes — a bigger change with
its own tradeoffs — for a window that is already this narrow and
self-correcting. If it ever needs to be closed, the options are: gate
auth_handler's subscriptions on Phase 2 completing too (defeats the
whole point of separating the phases), or have party_cache::load()
distinguish "no token provider yet" from a real auth failure so it is
diagnosable rather than a generic warning.
Checklist
[ ]Every subject the service owns is subscribed in Phase 1, before any Phase 2 activity — including subjects the service might call on itself.[ ]Anymake_service_token_providercall — self-referential or not — happens in Phase 2, after all of Phase 1'ssubscribecalls.[ ]Any cache warm-up (nats-event-cache-generated or hand-written) happens in Phase 2, after its producer's subjects (if the same service) are subscribed, and with whatever auth token its subjects now require.[ ]on_started(heartbeat, etc.) still runs last, as today.[ ]If a Phase-1-subscribed handler depends on a Phase-2 activity (e.g. a cache), confirm the failure mode during that narrow window is self-healing and non-silent-in-a-harmful-way — see "Known limitation" above — not a hard error users would notice.
See also
- Anatomy of a Service — the full per-service checklist this doc's Phase 1/Phase 2 split slots into (registry, certs, IAM accounts/roles, controller definition, runner).
- Service-to-Service Auth: Client Credentials vs. On-Behalf-Of Delegation — when to mint vs. delegate; this doc is about when in the startup sequence minting is safe.
- Generic entity-mirror cache primitive + codegen facet (party as
first consumer) — the story whose
read_for_cacheauth fix surfaced this ordering question. - Entity-composed registrars — how Phase 1's per-entity
register_<entity>_handlers()calls compose into one registrar. - Unified Service Hosting — the
run=/=run_signingrunners that host both phases.