Skip to main content

cortex/models/
worker_metadata.rs

1#![allow(clippy::extra_unused_lifetimes)]
2use std::collections::HashMap;
3use std::sync::mpsc::{SyncSender, sync_channel};
4use std::thread;
5use std::time::{Duration, SystemTime};
6
7use diesel::result::Error;
8use diesel::*;
9
10use serde::Serialize;
11
12use crate::backend::DbPool;
13use crate::models::Service;
14use crate::schema::worker_metadata;
15
16#[derive(Insertable, Debug)]
17#[diesel(table_name = worker_metadata)]
18/// Metadata collection for workers, updated by the dispatcher upon zmq transactions
19pub struct NewWorkerMetadata {
20  /// associated service for this worker metadata set
21  pub service_id: i32,
22  /// time of last ventilator dispatch to the service
23  pub last_dispatched_task_id: i64,
24  /// time of last sink job received from the service
25  pub last_returned_task_id: Option<i64>,
26  /// dispatch totals
27  pub total_dispatched: i32,
28  /// return totals
29  pub total_returned: i32,
30  /// first registered ventilator request for this worker, coincides with insertion in DB
31  pub first_seen: SystemTime,
32  /// first time seen in the current dispatcher session
33  pub session_seen: Option<SystemTime>,
34  /// time of last dispatched task
35  pub time_last_dispatch: SystemTime,
36  /// time of last returned job result
37  pub time_last_return: Option<SystemTime>,
38  /// identity of this worker, usually hostname:pid
39  pub name: String,
40}
41
42#[derive(Identifiable, Queryable, Associations, Clone, Debug, Serialize)]
43#[diesel(table_name = worker_metadata)]
44#[diesel(belongs_to(Service, foreign_key = service_id))]
45/// Metadata collection for workers, updated by the dispatcher upon zmq transactions
46pub struct WorkerMetadata {
47  /// task primary key, auto-incremented by postgresql
48  pub id: i32,
49  /// associated service for this worker metadata set
50  pub service_id: i32,
51  /// time of last ventilator dispatch to the service
52  pub last_dispatched_task_id: i64,
53  /// time of last sink job received from the service
54  pub last_returned_task_id: Option<i64>,
55  /// dispatch totals
56  pub total_dispatched: i32,
57  /// return totals
58  pub total_returned: i32,
59  /// first registered ventilator request for this worker, coincides with insertion in DB
60  pub first_seen: SystemTime,
61  /// first time seen in the current dispatcher session
62  pub session_seen: Option<SystemTime>,
63  /// time of last dispatched task
64  pub time_last_dispatch: SystemTime,
65  /// time of last returned job result
66  pub time_last_return: Option<SystemTime>,
67  /// identity of this worker, usually hostname:pid
68  pub name: String,
69}
70
71impl From<WorkerMetadata> for HashMap<String, String> {
72  fn from(worker: WorkerMetadata) -> HashMap<String, String> {
73    let mut wh = HashMap::new();
74    wh.insert("id".to_string(), worker.id.to_string());
75    wh.insert("service_id".to_string(), worker.service_id.to_string());
76    wh.insert(
77      "last_dispatched_task_id".to_string(),
78      worker.last_dispatched_task_id.to_string(),
79    );
80    wh.insert(
81      "last_returned_task_id".to_string(),
82      match worker.last_returned_task_id {
83        Some(id) => id.to_string(),
84        _ => String::new(),
85      },
86    );
87    wh.insert(
88      "total_dispatched".to_string(),
89      worker.total_dispatched.to_string(),
90    );
91    wh.insert(
92      "total_returned".to_string(),
93      worker.total_returned.to_string(),
94    );
95    // Per-worker outstanding = dispatched − returned: tasks this worker took but hasn't returned a
96    // result for. The actionable per-worker signal the fleet summary deliberately omits as an
97    // aggregate (KNOWN_ISSUES P-3) — a stale row with a large outstanding is a worker that died or
98    // stopped returning results. `saturating_sub` guards the impossible returned>dispatched case.
99    wh.insert(
100      "outstanding".to_string(),
101      worker
102        .total_dispatched
103        .saturating_sub(worker.total_returned)
104        .to_string(),
105    );
106
107    // Absolute UTC timestamps (RFC 3339); the UI localizes them to the viewer's zone with its code
108    // (public/js/localtime.js), and the fresh/stale row coloring still conveys liveness at a
109    // glance.
110    wh.insert("first_seen".to_string(), iso_utc_system(worker.first_seen));
111    wh.insert(
112      "session_seen".to_string(),
113      worker.session_seen.map(iso_utc_system).unwrap_or_default(),
114    );
115    wh.insert(
116      "time_last_dispatch".to_string(),
117      iso_utc_system(worker.time_last_dispatch),
118    );
119    wh.insert(
120      "time_last_return".to_string(),
121      worker
122        .time_last_return
123        .map(iso_utc_system)
124        .unwrap_or_default(),
125    );
126    wh.insert(
127      "fresh".to_string(),
128      if worker_is_fresh(&worker) {
129        "fresh"
130      } else {
131        "stale"
132      }
133      .to_string(),
134    );
135    wh.insert("name".to_string(), worker.name);
136    wh
137  }
138}
139
140/// Formats a `SystemTime` as an RFC 3339 UTC timestamp (seconds precision) — the zone-unambiguous
141/// form the UI localizes to the viewer's zone (public/js/localtime.js); empty timestamps render as
142/// an empty cell.
143pub fn iso_utc_system(then: SystemTime) -> String {
144  chrono::DateTime::<chrono::Utc>::from(then).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
145}
146
147/// Whether the worker has dispatched-to or returned-a-result within the last minute — the
148/// at-a-glance liveness flag driving the row coloring. Skew-safe: a future timestamp (clock skew
149/// across the fleet's hosts, or a DB clock running ahead) counts as fresh rather than
150/// `.unwrap()`-panicking on the `/workers/<service>` request path (no-panic-on-request-path
151/// mandate; cf. KNOWN_ISSUES F-4).
152fn worker_is_fresh(worker: &WorkerMetadata) -> bool { worker.is_fresh() }
153
154impl WorkerMetadata {
155  /// Whether this worker dispatched-to or returned-a-result within the last minute — its
156  /// at-a-glance liveness (the threshold that drives the fleet-screen row coloring and the
157  /// fleet-health summary). Skew-safe: a future timestamp (clock skew across the fleet's hosts, or
158  /// a DB clock running ahead) counts as fresh rather than `.unwrap()`-panicking (KNOWN_ISSUES
159  /// F-4).
160  pub fn is_fresh(&self) -> bool {
161    let last_active = match self.time_last_return {
162      Some(returned) => returned.max(self.time_last_dispatch),
163      None => self.time_last_dispatch,
164    };
165    SystemTime::now()
166      .duration_since(last_active)
167      .map(|elapsed| elapsed.as_secs() < 60)
168      .unwrap_or(true)
169  }
170
171  /// Load worker metadata record by identity and service id
172  pub fn find_by_name(
173    identity: &str,
174    sid: i32,
175    connection: &mut PgConnection,
176  ) -> Result<WorkerMetadata, Error> {
177    use crate::schema::worker_metadata::{name, service_id};
178    worker_metadata::table
179      .filter(name.eq(identity))
180      .filter(service_id.eq(sid))
181      .get_result(connection)
182  }
183
184  /// The **currently-active** fleet summary (for the dashboard, `/metrics`, and `cortex status`):
185  /// the number of workers that dispatched or returned a task within
186  /// [`ACTIVE_WORKER_WINDOW_SECS`], and the tasks in-flight (`dispatched − returned`) summed
187  /// across **those** workers. Filtering by recent activity is what makes this a truthful "what's
188  /// happening now" signal: without it, an idle deployment whose `worker_metadata` rows are
189  /// months stale reports a large phantom fleet and a meaningless cumulative-lifetime in-flight
190  /// gap (the KNOWN_ISSUES **P-3** confusion). With no dispatcher running this correctly returns
191  /// `(0, 0)`. One aggregate query over the small `worker_metadata` table — cheap per scrape.
192  pub fn fleet_summary(connection: &mut PgConnection) -> Result<(i64, i64), Error> {
193    use crate::schema::worker_metadata::dsl;
194    let active_since = SystemTime::now()
195      .checked_sub(Duration::from_secs(ACTIVE_WORKER_WINDOW_SECS))
196      .unwrap_or(SystemTime::UNIX_EPOCH);
197    let (count, in_flight): (i64, Option<i64>) = worker_metadata::table
198      // Active = dispatched or returned a task within the window. `time_last_dispatch` is NOT NULL;
199      // a NULL `time_last_return` simply doesn't satisfy that arm (a never-returned worker still
200      // counts if it dispatched recently).
201      .filter(
202        dsl::time_last_dispatch
203          .ge(active_since)
204          .or(dsl::time_last_return.ge(active_since)),
205      )
206      .select((
207        diesel::dsl::count_star(),
208        diesel::dsl::sum(dsl::total_dispatched - dsl::total_returned),
209      ))
210      .first(connection)?;
211    Ok((count, in_flight.unwrap_or(0)))
212  }
213
214  /// The most-recently-active workers, newest `time_last_dispatch` first — the admin live-activity
215  /// feed's "what the fleet is doing now" list. A cheap ordered read over the small
216  /// `worker_metadata` table (read-only; the dispatcher is never in the loop).
217  pub fn recent(connection: &mut PgConnection, limit: i64) -> Result<Vec<WorkerMetadata>, Error> {
218    use crate::schema::worker_metadata::dsl;
219    worker_metadata::table
220      .order(dsl::time_last_dispatch.desc())
221      .limit(limit)
222      .load(connection)
223  }
224}
225
226/// How recently a worker must have dispatched or returned a task to count as **active** in
227/// [`WorkerMetadata::fleet_summary`]. Workers process tasks frequently, so ten minutes captures the
228/// actively-converting fleet (even mid-conversion on a slow `latexml` document) while excluding
229/// rows left stale by a finished — or never-started — run. So a deployment with no dispatcher
230/// reports an empty fleet rather than a phantom one (P-3).
231const ACTIVE_WORKER_WINDOW_SECS: u64 = 600;
232
233/// Capacity of the worker-metadata event queue. A few seconds of headroom at the deployment's
234/// ~100–200 events/s; if the writer falls this far behind (e.g. the DB is stalled), further events
235/// are dropped rather than growing memory or blocking dispatch — observability metadata is
236/// best-effort under overload (cf. backpressure, KNOWN_ISSUES D-6).
237const METADATA_QUEUE_BOUND: usize = 8192;
238
239/// A worker-metadata event enqueued by the ventilator (dispatch) or sink (return) for the single
240/// background writer to apply as an upsert.
241enum WorkerEvent {
242  /// A task was dispatched to `name` for `service_id` (carrying the dispatched task id).
243  Dispatched {
244    /// Worker identity.
245    name: String,
246    /// Service the worker is registered against.
247    service_id: i32,
248    /// The dispatched task id.
249    task_id: i64,
250  },
251  /// A result was returned by `name` for `service_id` (carrying the returned task id).
252  Received {
253    /// Worker identity.
254    name: String,
255    /// Service the worker is registered against.
256    service_id: i32,
257    /// The returned task id.
258    task_id: i64,
259  },
260  /// `name` pinged for work but the queue was empty (a mock-reply): a liveness heartbeat that
261  /// refreshes `time_last_dispatch` ("last dispatch requested") WITHOUT counting a dispatch, so a
262  /// drained corpus's idle pollers don't inflate `total_dispatched` / outstanding.
263  Heartbeat {
264    /// Worker identity.
265    name: String,
266    /// Service the worker is registered against.
267    service_id: i32,
268  },
269}
270
271/// A cloneable, non-blocking handle the ventilator and sink use to enqueue worker-metadata events.
272/// Sends never block the dispatch hot loop: if the background writer is saturated the event is
273/// dropped (see [`METADATA_QUEUE_BOUND`]).
274#[derive(Clone)]
275pub struct WorkerMetadataSender {
276  tx: SyncSender<WorkerEvent>,
277}
278impl WorkerMetadataSender {
279  /// Enqueue a dispatch event (non-blocking, best-effort).
280  pub fn dispatched(&self, name: String, service_id: i32, task_id: i64) {
281    let _ = self.tx.try_send(WorkerEvent::Dispatched {
282      name,
283      service_id,
284      task_id,
285    });
286  }
287  /// Enqueue a return event (non-blocking, best-effort).
288  pub fn received(&self, name: String, service_id: i32, task_id: i64) {
289    let _ = self.tx.try_send(WorkerEvent::Received {
290      name,
291      service_id,
292      task_id,
293    });
294  }
295  /// Enqueue a liveness heartbeat for an empty-queue ping (non-blocking, best-effort). Refreshes
296  /// the worker's last-seen time without counting a dispatch.
297  pub fn heartbeat(&self, name: String, service_id: i32) {
298    let _ = self
299      .tx
300      .try_send(WorkerEvent::Heartbeat { name, service_id });
301  }
302}
303
304/// Spawns the single background worker-metadata writer and returns a cloneable
305/// [`WorkerMetadataSender`]. The writer drains events and applies the race-free upserts on pooled
306/// connections; it exits cleanly once every sender has dropped. This bounds metadata bookkeeping to
307/// **one** thread regardless of dispatch rate — replacing the unbounded thread-per-event spawn
308/// (KNOWN_ISSUES D-1) — while keeping the DB work off the dispatch hot path.
309pub fn start_metadata_writer(pool: DbPool) -> WorkerMetadataSender {
310  let (tx, rx) = sync_channel::<WorkerEvent>(METADATA_QUEUE_BOUND);
311  let _ = thread::spawn(move || {
312    for event in rx {
313      // Pooled checkout (~11µs) vs a fresh PgConnection (~4.5ms, the Arm 14 spike).
314      let mut pooled = match pool.get() {
315        Ok(connection) => connection,
316        Err(_) => continue,
317      };
318      let now = SystemTime::now();
319      let result = match event {
320        WorkerEvent::Dispatched {
321          name,
322          service_id,
323          task_id,
324        } => upsert_dispatched(&mut pooled, &name, service_id, task_id, now),
325        WorkerEvent::Received {
326          name,
327          service_id,
328          task_id,
329        } => upsert_received(&mut pooled, &name, service_id, task_id, now),
330        WorkerEvent::Heartbeat { name, service_id } => {
331          upsert_heartbeat(&mut pooled, &name, service_id, now)
332        },
333      };
334      if let Err(error) = result {
335        eprintln!("-- worker metadata writer: upsert failed: {error:?}");
336      }
337    }
338  });
339  WorkerMetadataSender { tx }
340}
341
342/// Inserts — or, on `(name, service_id)` conflict, increments — the dispatch tallies for a worker.
343/// `session_seen`/`first_seen` are preserved on conflict (the row already carries them).
344fn upsert_dispatched(
345  connection: &mut PgConnection,
346  name: &str,
347  service_id: i32,
348  last_dispatched_task_id: i64,
349  now: SystemTime,
350) -> QueryResult<usize> {
351  insert_into(worker_metadata::table)
352    .values(&NewWorkerMetadata {
353      name: name.to_string(),
354      service_id,
355      last_dispatched_task_id,
356      last_returned_task_id: None,
357      total_dispatched: 1,
358      total_returned: 0,
359      first_seen: now,
360      session_seen: Some(now),
361      time_last_dispatch: now,
362      time_last_return: None,
363    })
364    .on_conflict((worker_metadata::name, worker_metadata::service_id))
365    .do_update()
366    .set((
367      worker_metadata::last_dispatched_task_id.eq(last_dispatched_task_id),
368      worker_metadata::total_dispatched.eq(worker_metadata::total_dispatched + 1),
369      worker_metadata::time_last_dispatch.eq(now),
370    ))
371    .execute(connection)
372}
373
374/// Inserts — or, on `(name, service_id)` conflict, updates — only the liveness timestamp for a
375/// worker that pinged an empty queue. Unlike [`upsert_dispatched`] it does NOT bump
376/// `total_dispatched` (the ping leased no task); it just refreshes `time_last_dispatch` ("last
377/// dispatch requested") so an idle-but-alive worker still reads as fresh/active. The insert branch
378/// seeds a zero-tally row for a worker whose very first contact was an empty ping.
379fn upsert_heartbeat(
380  connection: &mut PgConnection,
381  name: &str,
382  service_id: i32,
383  now: SystemTime,
384) -> QueryResult<usize> {
385  insert_into(worker_metadata::table)
386    .values(&NewWorkerMetadata {
387      name: name.to_string(),
388      service_id,
389      last_dispatched_task_id: 0,
390      last_returned_task_id: None,
391      total_dispatched: 0,
392      total_returned: 0,
393      first_seen: now,
394      session_seen: Some(now),
395      time_last_dispatch: now,
396      time_last_return: None,
397    })
398    .on_conflict((worker_metadata::name, worker_metadata::service_id))
399    .do_update()
400    .set((worker_metadata::time_last_dispatch.eq(now),))
401    .execute(connection)
402}
403
404/// Inserts — or, on conflict, increments — the return tallies for a worker. The insert branch
405/// covers the out-of-order case (a result recorded before the worker's first dispatch): it seeds a
406/// row whose dispatch fields are placeholders (`last_dispatched_task_id = 0`, `total_dispatched =
407/// 0`) that the eventual dispatch upsert corrects.
408fn upsert_received(
409  connection: &mut PgConnection,
410  name: &str,
411  service_id: i32,
412  last_returned_task_id: i64,
413  now: SystemTime,
414) -> QueryResult<usize> {
415  insert_into(worker_metadata::table)
416    .values(&NewWorkerMetadata {
417      name: name.to_string(),
418      service_id,
419      last_dispatched_task_id: 0,
420      last_returned_task_id: Some(last_returned_task_id),
421      total_dispatched: 0,
422      total_returned: 1,
423      first_seen: now,
424      session_seen: Some(now),
425      time_last_dispatch: now,
426      time_last_return: Some(now),
427    })
428    .on_conflict((worker_metadata::name, worker_metadata::service_id))
429    .do_update()
430    .set((
431      worker_metadata::last_returned_task_id.eq(last_returned_task_id),
432      worker_metadata::total_returned.eq(worker_metadata::total_returned + 1),
433      worker_metadata::time_last_return.eq(now),
434    ))
435    .execute(connection)
436}
437
438#[cfg(test)]
439mod tests {
440  use super::*;
441  use crate::backend;
442  use crate::schema::worker_metadata as wm;
443
444  const SERVICE_ID: i32 = 1; // worker_metadata has no FK to services, so any id is fine here
445
446  fn clear(connection: &mut PgConnection, worker: &str) {
447    diesel::delete(wm::table.filter(wm::name.eq(worker)))
448      .execute(connection)
449      .ok();
450  }
451
452  fn load(connection: &mut PgConnection, worker: &str) -> WorkerMetadata {
453    WorkerMetadata::find_by_name(worker, SERVICE_ID, connection).expect("worker row")
454  }
455
456  #[test]
457  fn received_before_dispatched_does_not_drop_the_count() {
458    let worker = "wm-test:out-of-order:1";
459    let mut backend = backend::testdb();
460    let connection = &mut backend.connection;
461    clear(connection, worker);
462    let now = SystemTime::now();
463
464    // Out-of-order: a result is recorded before the worker's first dispatch. The old
465    // find-then-update silently dropped this; the upsert must seed the row instead.
466    upsert_received(connection, worker, SERVICE_ID, 42, now).expect("received upsert");
467    let row = load(connection, worker);
468    assert_eq!(row.total_returned, 1, "the early return is not dropped");
469    assert_eq!(row.total_dispatched, 0, "no dispatch counted yet");
470    assert_eq!(row.last_returned_task_id, Some(42));
471
472    // The dispatch then lands and corrects the dispatch fields without losing the return.
473    upsert_dispatched(connection, worker, SERVICE_ID, 99, now).expect("dispatched upsert");
474    let row = load(connection, worker);
475    assert_eq!(row.total_dispatched, 1, "dispatch now counted");
476    assert_eq!(
477      row.total_returned, 1,
478      "return preserved across the dispatch upsert"
479    );
480    assert_eq!(row.last_dispatched_task_id, 99);
481
482    clear(connection, worker);
483  }
484
485  #[test]
486  fn heartbeat_refreshes_liveness_without_counting_a_dispatch() {
487    let worker = "wm-test:heartbeat:1";
488    let mut backend = backend::testdb();
489    let connection = &mut backend.connection;
490    clear(connection, worker);
491    let early = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
492    let later = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000);
493
494    // A first contact that is an empty-queue ping seeds a live row with zero tallies.
495    upsert_heartbeat(connection, worker, SERVICE_ID, early).expect("heartbeat seed");
496    let row = load(connection, worker);
497    assert_eq!(row.total_dispatched, 0, "a ping is not a dispatch");
498    assert_eq!(row.total_returned, 0);
499
500    // A real lease lands and IS counted.
501    upsert_dispatched(connection, worker, SERVICE_ID, 7, early).expect("dispatched");
502    // Subsequent idle pings only advance the timestamp — outstanding must stay at 1, not climb.
503    upsert_heartbeat(connection, worker, SERVICE_ID, later).expect("heartbeat refresh");
504    upsert_heartbeat(connection, worker, SERVICE_ID, later).expect("heartbeat refresh");
505    let row = load(connection, worker);
506    assert_eq!(
507      row.total_dispatched, 1,
508      "idle pings did not inflate dispatched"
509    );
510    assert_eq!(
511      row.total_dispatched - row.total_returned,
512      1,
513      "outstanding reflects the one real in-flight task, not the pings"
514    );
515    assert_eq!(
516      row.time_last_dispatch, later,
517      "the ping refreshed last-dispatch-requested"
518    );
519    assert_eq!(
520      row.last_dispatched_task_id, 7,
521      "the real lease id is preserved"
522    );
523
524    clear(connection, worker);
525  }
526
527  #[test]
528  fn repeated_events_accumulate_in_one_row() {
529    let worker = "wm-test:accumulate:1";
530    let mut backend = backend::testdb();
531    let connection = &mut backend.connection;
532    clear(connection, worker);
533    let now = SystemTime::now();
534
535    for task in 0..3 {
536      upsert_dispatched(connection, worker, SERVICE_ID, task, now).expect("dispatched");
537    }
538    for task in 0..2 {
539      upsert_received(connection, worker, SERVICE_ID, task, now).expect("received");
540    }
541    // The unique constraint holds: exactly one row, with accumulated tallies.
542    let rows: i64 = wm::table
543      .filter(wm::name.eq(worker))
544      .count()
545      .get_result(connection)
546      .unwrap();
547    assert_eq!(rows, 1, "one row per (name, service_id)");
548    let row = load(connection, worker);
549    assert_eq!(row.total_dispatched, 3);
550    assert_eq!(row.total_returned, 2);
551
552    clear(connection, worker);
553  }
554}