tonin_core/job.rs
1//! Background-job entry point.
2//!
3//! A "job" in micro is a binary that runs to completion (queue consumer,
4//! scheduled task, one-shot migration runner) rather than serving gRPC.
5//! It shares the service crate, so it sees the same `State` and the same
6//! `tonin::auth` setup — but it doesn't bind a port and doesn't run
7//! the inbound auth layer.
8//!
9//! ## What `bootstrap` does
10//!
11//! 1. Initialize OTel (same `crate::telemetry::init` the server uses,
12//! so traces / metrics export to the same collector).
13//! 2. Mint a **service-identity** `AuthCtx` via [`crate::auth::service_token`].
14//! There's no incoming request to extract auth from, so the framework
15//! mints one representing *this service* and the job propagates it on
16//! outbound RPCs.
17//! 3. Build a [`crate::State`] from env (Postgres + Redis lazily, same as `main.rs`).
18//!
19//! ## Usage
20//!
21//! ```ignore
22//! use tonin::prelude::*;
23//!
24//! #[tokio::main]
25//! async fn main() -> Result<()> {
26//! let ctx = tonin::job::bootstrap("greeter-cleanup").await?;
27//!
28//! // Use ctx.state.pg() / ctx.state.redis() for queries.
29//! // Use ctx.auth.propagate(&mut req) on outbound calls so the
30//! // callee sees a service principal rather than anonymous.
31//! tracing::info!(job = "greeter-cleanup", subject = %ctx.auth.subject, "starting");
32//!
33//! // ... your job logic ...
34//! Ok(())
35//! }
36//! ```
37//!
38//! ## Spawn pitfall
39//!
40//! [`crate::auth::CURRENT_AUTH`] is task-local and gets set by the
41//! server's auth layer; **jobs don't set it** because there's no
42//! inbound request. If you `tokio::spawn` from inside a job, capture
43//! `ctx.auth` before the spawn and pass it explicitly.
44
45use crate::auth::AuthCtx;
46use crate::error::{Error, Result};
47use crate::state::State;
48
49/// Bootstrap output: identity + pre-wired storage. Cheap to clone.
50#[derive(Clone)]
51pub struct JobCtx {
52 /// Service-identity auth context. Use [`AuthCtx::propagate`] on
53 /// outbound requests so downstream services see this as a
54 /// `PrincipalKind::Service` call.
55 pub auth: AuthCtx,
56 /// Postgres + Redis handles, lazily resolved from env. Same shape
57 /// as the gRPC server's state.
58 pub state: State,
59}
60
61/// Initialize telemetry, mint a service-identity token, and resolve
62/// state. Designed to be the second line of every job binary's `main`
63/// (the first being `#[tokio::main]`).
64///
65/// **Errors:** any of (a) the service-token minter isn't configured
66/// (`TONIN_AUTH_SERVICE_TOKEN_URL` unset), (b) the auth service is
67/// unreachable, (c) `DATABASE_URL` / `REDIS_URL` was set but the dep
68/// is unreachable. All three are deploy-time problems; failing the
69/// job at bootstrap is the right move.
70pub async fn bootstrap(name: impl Into<String>) -> Result<JobCtx> {
71 let name = name.into();
72
73 // Telemetry init mirrors what `Service::new` does. We don't go
74 // through Service because we don't want a port bound — but we DO
75 // want the same OTel exporters wired up so the job's spans land
76 // alongside the server's.
77 if let Err(e) = crate::telemetry::init(&name) {
78 // Non-fatal: keep the job running even if telemetry export
79 // can't be set up. Same posture as the server.
80 eprintln!("micro: telemetry init failed: {e}");
81 }
82 tracing::info!(target: "tonin::job", %name, "job bootstrapping");
83
84 let auth = crate::auth::service_token()
85 .await
86 .map_err(|e| Error::Config(format!("service token mint: {e}")))?;
87 let state = State::from_env().await?;
88
89 tracing::info!(
90 target: "tonin::job",
91 %name,
92 subject = %auth.subject,
93 has_pg = state.has_pg(),
94 has_redis = state.has_redis(),
95 "job ready",
96 );
97 Ok(JobCtx { auth, state })
98}