Skip to content

[fm] Add saga diagnosis engine#10592

Open
smklein wants to merge 52 commits into
mainfrom
fm-saga-diagnoser
Open

[fm] Add saga diagnosis engine#10592
smklein wants to merge 52 commits into
mainfrom
fm-saga-diagnoser

Conversation

@smklein

@smklein smklein commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Adds the a fault management diagnosis engine: the problematic saga diagnoser.

What it does

Opens a case (keyed by saga ID) for any unfinished saga that needs attention.

This PR uses three fact kinds:

  • NotProgressing: a live (running or unwinding) saga has recorded no node event for longer than STALE_SAGA_THRESHOLD (this is 30 minutes, but we can set it to whatever).
  • OwnerNotCurrentGeneration: the saga's current_sec is a Nexus that will never advance it, either quiesced (an older generation that handed off) or expunged (no db_metadata_nexus record).
  • Abandoned: Nexus permanently gave up on recovering the saga.

Inputs

The preparation phase builds an ObservedSaga projection, the saga analog of #10541's InServiceDisk: all unfinished sagas (running, unwinding, or abandoned), joined with each saga's latest node-event time and its owner's classification against db_metadata_nexus. This is the executed view read directly from the database, and it is included in the analysis input report so "why did FM (not) flag this saga" is answerable from a sitrep.

Case lifecycle

The two live-saga facts may coexist on one case; as conditions change, facts are removed and re-added under the same case (with stable fact UUIDs when the observation is unchanged). The case closes when the saga completes, or when every condition clears (progress resumes, owner re-adopted), and a recurrence later opens a fresh case. An abandoned saga's case never closes on its own; it persists, carrying the Abandoned fact, until remediation removes the saga row. Actually remediating an Abandoned saga seems like a case-by-case resolution, so I'm not doing it in this PR!

Context

Fixes #10530

smklein added 20 commits June 3, 2026 11:34
The second fault management diagnosis engine: opens a case (keyed by
saga_id) for any non-terminal saga that is either not making progress
(no node event recorded within STALE_SAGA_THRESHOLD) or orphaned (owned
by a Nexus that is no longer of the current generation). These are two
independent fact kinds; a saga's case may carry either or both. A case
closes when the saga reaches a terminal state. Tracks #10530.

Supporting infrastructure:

- DiagnosisEngineKind::Saga variant (Rust + DB enum)
- fm_fact_saga typed child table with two fact kinds (not_progressing,
  owner_not_current_generation), per-kind nullable columns gated by a
  CHECK, participating in copy-forward + GC like other sitrep child
  tables
- SagaFact / FactPayload::Saga and the ObservedSaga nexus-types
  projection
- saga_list_running_or_unwinding_batched and a grouped
  saga_latest_node_event_times datastore query (the wall-clock progress
  signal); owner currency read DB-direct via the existing
  get_db_metadata_nexus_in_state

Schema migration: fm-saga-de (version 263) adds the 'saga' enum value,
the fm_fact_saga_kind and fm_fact_saga_orphan_reason enums, and the
fm_fact_saga table.
…lerate duplicate zpools

- The per-kind CHECK constraint on fm_fact_physical_disk is now an
  implication (kind != 'zpool_unhealthy' OR columns present), so future
  fact kinds add their own constraint instead of rewriting this one
- The fm_analysis in-service disk projection skips soft-deleted
  physical_disk rows, matching what its comment already claimed
- A duplicate physical disk in the zpool listing now logs a warning and
  keeps the first zpool instead of panicking the background task
SitrepBuilder::cases is seeded from the open cases of the Input the
builder was constructed from, but diagnosis::analyze() previously took
the Input as a separate argument; nothing tied the two together, and an
engine handed a mismatched pair would panic looking up a case that was
never seeded. Store the Input on the builder and have engines read it
from there, making the mismatch unrepresentable.
Facts are only read during sitrep load, which paginates over the
(sitrep_id, id) primary key; nothing queries by case directly. Easy to
re-add with a migration if omdb grows a case-scoped fact query.
Replace the per-disk linear scan of parent cases with a one-time
inverse index. First case ID wins on (pathological) collisions, same
as the scan it replaces.
Match the fm_fact_physical_disk convention: each kind's constraint
validates that the columns it expects are present, and future kinds add
their own constraint instead of rewriting an exhaustive CASE. This also
stops enforcing that other kinds' columns are NULL, leaving room for
kinds to share columns.
A case is an episode of a problem, not a dossier on the saga. When a
flagged saga is still running but no condition holds anymore (progress
resumed, owner re-adopted by a current Nexus), close the case rather
than stripping its facts and leaving it open. Previously the case would
be left open with zero facts, making it uninterpretable to the next
analysis pass, which would then carry it forward unexamined forever,
even after the saga terminated.

The closing case keeps its facts attached as the record of why it
existed; they age out with the case once it stops being copied forward
and its sitreps are GC'd.
Facts are only read during sitrep load, which paginates over the
(sitrep_id, id) primary key; nothing queries by case directly. Matches
the same change to fm_fact_physical_disk.
The input report listed in-service disks but said nothing about the
sagas visible to the saga diagnosis engine, leaving no way to answer
"why did FM (not) flag this saga" from the report. List each
non-terminal saga with its name, state, latest node-event time, and
owner classification.
A mass-stuck-saga incident is exactly when this query runs with a large
ID list; chunk the eq_any so it never becomes one giant statement.
Chunks are disjoint, so GROUP BY keys never cross them and the
concatenated results are identical to the single-statement form.
A saga case carries at most one fact per kind. The parent-case summary
previously kept whichever duplicate happened to have the highest fact
UUID and lost track of the rest, so a (pathological) duplicate would be
carried forward and re-persisted in every sitrep, and removing the
tracked copy while an untracked match survived would re-add a fresh
fact, regenerating the duplicate pair.

ParentSagaCase now separates the fact to consider when advancing the
case (the lowest fact UUID of each kind) from duplicates, which are
removed unconditionally with a warning. A corrupt case converges to one
fact per kind in a single pass.
A saga with no current_sec yields owner_state = None, not Absent; the
variant doc claimed otherwise. Also replace em-dashes in this file's
comments.
A fact payload contains exactly what defines the condition for the
analysis loop: the subject's ID plus the parameters whose change should
rotate the fact. Anything a human wants for presentation is looked up
from the database when a case is acted on; a case is only open while
its saga row still exists, so the lookup always works.

Accordingly:
- saga_name leaves both payloads and the fm_fact_saga table; it never
  defines a condition. Names still appear in debug comments.
- time_created leaves NotProgressing; the staleness condition folds it
  into last_event_time already.
- adopt_generation leaves OwnerNotCurrentGeneration; it is not
  condition-defining, and sagas_reassign_sec bumping it would have
  rotated the fact UUID over a meaningless ownership shuffle.
Per omicron#10581, Nexus will explicitly abandon sagas it fails to
recover for non-transient reasons. Abandonment is the beginning of an
escalation, not a resolution: the saga may be holding
partially-allocated resources and needs saga-specific manual
remediation (RFD 555). Without this change the engine would close the
case the moment a stuck saga was abandoned, exactly when the escalation
should begin.

- The projection now lists unfinished sagas (running, unwinding, or
  abandoned); only 'done' drops a saga from observation. ObservedSaga
  carries the three-variant ObservedSagaState; SagaProgressState
  remains the live-saga subset recorded in NotProgressing facts.
- New SagaFact::Abandoned with a pure-identity payload (the condition
  is boolean, so nothing can rotate it). Abandonment supersedes the
  live-saga conditions: NotProgressing and OwnerNotCurrentGeneration
  facts are removed when a saga is abandoned, and the case carries the
  Abandoned fact alone, staying open until the saga row is deleted.
- The module doc also records why the owner fact detects stranded
  sagas rather than wrongly-resumed ones, and what mitigates the
  latter.
@smklein smklein changed the title Fm saga diagnoser [fm] Add saga diagnosis engine Jun 11, 2026
smklein added 9 commits June 11, 2026 12:50
Hardening from a pre-review pass:

- Include zpool_id in the "same observation" comparison for facts, so a
  zpool that is destroyed and recreated (still unhealthy) rotates the
  fact instead of carrying a stale zpool reference forward.

- Close cases the diagnosis engine cannot interpret (foreign fact
  payload, facts disagreeing on the disk, no facts) and duplicate cases
  for the same disk, rather than carrying them forward unprocessable
  forever. Closing is safe for fault coverage: detection is independent
  of case bookkeeping, so a still-faulty disk gets a fresh well-formed
  case in the same analysis pass. Reasons are a typed enum
  (UninterpretableCase) surfaced in the close comment and warn logs.

- In the omdb output test, drive the FM tasks to their steady state
  with explicit background task activations (analysis -> loader ->
  analysis -> rendezvous) instead of racing the watch-channel triggers.
  This makes the expected output deterministic for both fm_analysis and
  fm_rendezvous, and removes the sitrep_load_rx test plumbing
  (NexusServer trait method, accessors, wait helper) that existed only
  to support the old wait.
Apply the lessons from the disk diagnoser review (the same pattern
landed there in 6f2e4a5):

- Close cases the engine cannot interpret (foreign fact payload, facts
  disagreeing on the saga, no facts) instead of skipping them, which
  left them open and unprocessable in every future sitrep with no path
  to closure. Reasons are a typed UninterpretableCase enum surfaced in
  the close comment and warn logs. Closing is safe for fault coverage:
  detection iterates observed sagas independently of case bookkeeping,
  so a saga that still needs attention gets a fresh well-formed case in
  the same pass.

- Close duplicate cases for the same saga as superseded, keeping the
  lowest case ID. Previously the last-indexed case silently won the
  saga_id index while both cases had stale facts removed, so the loser
  decayed into an empty open case that could never close.

- New tests: empty case closed, duplicate closed, corrupt case replaced
  by a fresh one in the same pass, and foreign-payload cases closed in
  both engines (newly testable, since two FactPayload variants now
  exist).
Previously from_sitrep() set a default kind in the base row that every
match arm had to remember to override; a future DiskFact variant that
forgot would silently inherit the wrong discriminant. Route kind
through an exhaustive match on the payload so that can't compile.
Remove the unused CaseBuilder::facts() accessor, collapse the three
identical multi-line .expect() strings in analyze() to one short
message, and drop the redundant case-level comment on a freshly opened
disk case (its specifics already live in the fact added alongside it).
Lets the per-DE row constructor take (metadata, payload) instead of a
whole Fact plus a redundant DiskFact, removing the consistency footgun
where the two could disagree. Mirrors Case's metadata/payload split.
observed_health -> observed_zpool_health (keeps the load-bearing
'observed' semantic), summarize_case -> parse_case (it validates and
rejects, not just condenses), and ParentCaseSummary -> ParsedDiskCase to
match.
Mirror the disk diagnoser cleanups in the saga engine, now that both
share the FactMetadata/payload split:

- summarize_case -> parse_case (it validates and rejects, not just
  condenses) and ParentSagaCase -> ParsedSagaCase to match.
- observed -> observed_sagas.
- Close uninterpretable cases inline in the parse loop instead of
  collecting them into a vec and looping again.
- Build case_for_saga in a single pass with the Entry API, closing
  duplicate cases inline; reframe the tie-break comment as
  arbitrary-but-deterministic rather than 'correct'.
- Separate case-closing from fact reconciliation: the first pass over
  surviving cases is now closing-only, and the second pass owns all of
  a saga's fact state (drop duplicate + stale facts, add fresh) in one
  place. A new covered() helper folds the three per-kind match arms.
- FmFactSaga::from_sitrep takes (metadata, payload) and destructures
  FactMetadata exhaustively, so a new field fails to compile until it
  is mapped to a column.
- Tests: replace the (Fact, SagaFact) tuple from saga_facts with a
  named SagaFactRef, and extract the repeated sole-fact lookup into a
  sole_saga_fact_id helper.
@smklein
smklein force-pushed the fm-saga-diagnoser branch from 90185bb to 5b48c08 Compare June 16, 2026 19:10
@smklein
smklein force-pushed the fm-saga-diagnoser branch from b5f8ee1 to dffc15c Compare June 16, 2026 21:29
smklein added 2 commits June 16, 2026 16:36
The fm_sitrep_gc task's "orphaned sitreps deleted" and "orphaned
fm_sitrep_analysis_report rows deleted" counts are timing-dependent.
When two fm_analysis activations overlap (e.g. the boot-time activation
and the explicit drive in the test), both can insert a first sitrep
before either is made current; the loser's sitrep and its stashed
analysis report are inserted but orphaned (fm_sitrep_insert's
ParentNotCurrent path), then deleted by a later GC pass. Whether that
race occurred before the omdb snapshot is non-deterministic, which made
test_omdb_success_cases flaky. Redact both counts.
Base automatically changed from fm-disk-diagnoser-typed to main June 18, 2026 01:52
smklein added 3 commits June 18, 2026 10:59
- Add datastore tests for saga_list_unfinished_batched and
  saga_latest_node_event_times
- Drop the unused ObservedSaga::adopt_generation field
- Move saga facts onto their own valid Saga cases in the sitrep
  round-trip test fixture, instead of riding a physical-disk case
- Remove the parse-time duplicate-fact warning; remove_fact already
  logs the removal and records it in the analysis report
- Drop allow(unreachable_patterns) in the FactPayload accessors, no
  longer needed now that the enum has two variants
- Comment cleanups: document NotYet owner coverage, fix inaccurate
  terminal-state wording, remove repeated jargon phrasing
@smklein
smklein marked this pull request as ready for review July 13, 2026 23:09
Remove comments that overpromise, editorialize, or describe behavior
far from where it lives; state the CHECK-constraint design fact instead
of instructing future kinds; drop caller-naming from a datastore doc.
let mut batch =
paginated(dsl::saga, dsl::id, &p.current_pagparams())
.filter(dsl::saga_state.eq_any(UNFINISHED_STATES))
.select(db::saga_types::Saga::as_select())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this is selecting everything including saga_dag (the JSON blob), which isn't used in prepare_observed_sagas. Is it easy enough to just project the columns that are needed for ObservedSaga?

@smklein smklein Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing, done in 77a6442. Good idea to not pull out all the JSON that we don't need!

pub in_service_disks: BTreeSet<PhysicalDiskUuid>,
/// All non-terminal sagas visible to the diagnosis engines for this
/// analysis pass.
pub observed_sagas: BTreeMap<steno::SagaId, ObservedSagaReport>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need #[serde(default)] here to make preexisting serialized instances parseable?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, added, and also adding a warning comment in 0f6b14e

(also went ahead and added this to in_service_disks, which probably should have had the same treatment)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3 thanks for adding the warning

);
logctx.cleanup_successful();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to add a test that running the DE twice doesn't change anything? Something like:

// run once with no parent sitrep
let input1 = build_input(collection.clone(), None, observed.clone());
let (sitrep1, _) = run_analyze(&logctx.log, &input1);

// run again with the sitrep1 as the parent
let input2 = build_input(collection, Some(sitrep1.clone()), observed);
let (sitrep2, _) = run_analyze(&logctx.log, &input2);

assert_eq!(
    sitrep1.compare_state(), sitrep2,
    "no changes must produce an identical sitrep to the parent",
);

This should really be a property of all DEs (cc @hawkw), not just this one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f070699. I do think that there is a bit of DE-specific machinery here though, we need to tickle the input in such a way that "the DE fires". If we passed "no sagas" to this DE and tried to validate the same thing, it'd be a little less interesting.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think in the eventual future, if we have a proptest thing for generating arbitrary inputs, we could maybe help ensure a test like this is interesting by rejecting any inputs where the first analysis step makes a sitrep that has nothing interesting in it, but I dunno. That might run for a long time.

Comment on lines +364 to +365
if reference_time.signed_duration_since(last_progress)
> STALE_SAGA_THRESHOLD

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the comment at the top of the file describing STALE_SAGA_THRESHOLD says "at least this long", but this is strict greater-than.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in ec0536a

Some(DbMetadataNexusState::Quiesced) => {
SagaOwnerState::Quiesced
}
None => SagaOwnerState::Absent,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we see SagaOwnerState::Absent in orphaned_reason, we map that to "expunged". That's mostly right, but I think it's not correct when nexus_states is empty. Should we skip this in that case? It'd sort of be analogous to DataStore::check_nexus_access in db_metadata, where we bail if the table is empty.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think nexus_states should ever be empty - this metadata is necessary for Nexuses to perform handoff, and therefore, it's necessary for them to boot. The only time it's empty is during rack initialization, when general database access is not active yet (and the FM subsystem isn't running yet).

(So, in another way, if we see a saga pointing to an owner without a nexus_state, it's a lot more likely that it has been expunged than that we have no nexus_states)

})
}

pub(super) fn analyze(builder: &mut SitrepBuilder<'_>) -> anyhow::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the comment I left on @hawkw's PSU DE (#10593 (comment)), I feel like there's some (future!) opportunity for us to extract the state-munging logic that all DEs need into some shared component. That is, I think the essence of this DE is roughly the three desired_* functions, and the rest is plumbing. Once the dust settles, we should try squinting at the DEs we've ended up with and see what we can pull out.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I definitely think case-parsing + "assigning case-specific facts" could be shared, as well as the "re-evaluation of existing vs new cases flow". but yeah, i do still want to punt for now, because generalizing too early seems painful in a different way

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I think once we have more than 2 DEs that care about facts, and more than 2 that care about ereports, we might be starting to exit the zone where trying to pull out abstractions is premature!

///
/// An abandoned saga has no meaningful owner (nobody will ever run it):
/// [`desired_abandoned`] supersedes the live-saga conditions.
fn desired_owner_not_current(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the expected worst-case gap between a Nexus's db_metadata_nexus row going away and sagas_reassign_sec re-adopting that Nexus's sagas? If it can span an analysis pass, we might open a case thinking the saga is orphaned, and then close it again when the saga is reassigned.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think your take here is correct: even within one blueprint execution, the metadata-row deletion and reassignment are different steps, but it could also span blueprints.

Either way, I think the DE is doing the correct thing by labeling "what it sees right now". If the DE sees a saga that currently has no owner, it might be undergoing re-assignment momentarily, or it might never happen.

So:

  • If we see a saga which deleted the db_metadata_nexus row but hasn't re-assigned yet, it does look (briefly!) orphaned
  • If we later see that saga which has been re-assigned, we'd close the case

My two cents: I think it's okay to open a case here, but you're right that we might not want to actually trigger an active problem here without some hysteresis. This case was originally motivated by #10530 (comment) , where an issue with steno prevented re-assignment.

So, TL;DR, I think the clarification for "is this suspect saga actually a problem" is answered by "is the issue temporary or permanent", which we could use as our case -> active problem promotion criteria.

smklein added 4 commits July 15, 2026 14:34
The saga DE input only needs id, name, time_created, saga_state, and
current_sec; skip fetching the saga DAG (the widest column in the
table) for every unfinished saga.
…persisting

Reports serialized before in_service_disks and observed_sagas existed
would otherwise fail to deserialize. Note the policy for future fields
at the end of the struct.
Suggested in review: with the first run's sitrep as parent and the same
observed sagas, the second sitrep must carry every case and fact forward
verbatim.

let mut batch =
paginated(dsl::saga, dsl::id, &p.current_pagparams())
.filter(dsl::saga_state.eq_any(UNFINISHED_STATES))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick q: I think there isn't a partial index filtered for WHERE saga_state != 'done' (or however you want to express "unfinished states"), so I think this query effectively has to scan all sagas. Would it be worth adding an index?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an index to catch this:

omicron/schema/crdb/dbinit.sql

Lines 2994 to 3000 in 007c97f

/*
* For recovery (and probably takeover), we need to be able to list running
* sagas by SEC. We need to paginate this list by the id.
*/
CREATE UNIQUE INDEX IF NOT EXISTS lookup_saga_by_sec ON omicron.public.saga (
current_sec, id
) WHERE saga_state != 'done';

I can phrase this as the inverse (saga_state.ne(done)) to match the index in a more obvious way?

Comment on lines +381 to +385
vec![
DbMetadataNexusState::Active,
DbMetadataNexusState::NotYet,
DbMetadataNexusState::Quiesced,
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: is there a way to derive this vec from the definition of DbMetadataNexusState so they don't get out of sync if somebody adds a variant later? Or do you mean to be explicit about these states being the only ones you care about?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could perhaps use strum to derive a DbMetadataNexusState::ALL_VARIANTS --- i've found that useful for other DB enum types where you have a query for looking them up by state but also want to sometimes say "don't care"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, strum seems reasonable. I'll use the "all variants" approach here.

Comment thread schema/crdb/dbinit.sql Outdated
-- not that others are NULL, so future kinds may share columns.
CONSTRAINT not_progressing_columns_present CHECK (
kind != 'not_progressing' OR (
saga_state IS NOT NULL

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think this constraint could be tighter - saga_state has to be running or unwinding, it can't be done or abandoned. Or we should just use a different enum type here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll make the check tighter. I'm trying to follow the convention we agreed upon for per-DE fact types (one DB table, distinguished by kind), but I'll make the saga_state check more explicit.

/// `time_done` of `observed_in_inv`.
pub time_observed: DateTime<Utc>,
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, i wonder if we might want to start moving the different fact types into submodules for the different DEs, since there's starting to be a lot of fact types in here. This has the advantage of not needing to prefix every type with the name of the DE that cares about it, and the DE can blanket import its submodule if it wants to. not a blocker, just a thought.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm down to do this, but I'd prefer to do it as a follow-up PR to make it a little more obvious what is "refactoring only".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread nexus/types/src/fm/fact.rs Outdated
Comment on lines +146 to +150
/// Payloads carry only the fields that define the condition: the subject's
/// ID, plus the parameters whose change means the condition itself changed
/// (which rotates the fact). Anything a human wants for presentation (e.g.,
/// the saga's name) is looked up from the database when a case is acted on;
/// a case is only open while its saga row still exists.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this comment feels a bit like claude gibberish, could we maybe rewrite this in a way that's a bit easier to read?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, updated the comment.

TL;DR, "only record what's relevant and might change (state + timestamp), everything else can be looked up by ID later".

Comment thread nexus/db-model/src/fm/fact_saga.rs Outdated
Comment on lines +87 to +90
Err(Error::internal_error(&format!(
"fm_fact_saga row has saga_state {state:?}, which is never \
recorded on a NotProgressing saga fact"
)))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is maybe obnoxious but i really don't like constructing errors with &format!, because it allocates a string, passes it by reference, and then the internal_error constructor ToStrings the &String, allocating a new string and throwing away the old one. we could write:

Suggested change
Err(Error::internal_error(&format!(
"fm_fact_saga row has saga_state {state:?}, which is never \
recorded on a NotProgressing saga fact"
)))
Err(Error::InternalError { internal_message: format!(
"fm_fact_saga row has saga_state {state:?}, which is never \
recorded on a NotProgressing saga fact"
)})

to avoid that...or, eventually, we should really get around to changing the function constructors for error types to take impl ToString, so you can just pass the string you already formatted and not have it formatted into a new string and thrown away...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, yeah, I can avoid the extra allocation by dodging the constructor function. I'll do that for the other callsites in this PR invoking Error::internal_error(&format!(...)) too.

Comment on lines +179 to +181
self.saga_state.ok_or_else(|| {
missing_column(kind, "saga_state")
})?,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it occurs to me that someday we could maybe make this pattern a bit less odious if we had a macro like

macro_rules! expect_column {
    ($row:ident, $colname:ident) => $row.$colname.ok_or_else(|| missing_column(kind, stringify!($colname))
}

except written in some kinda way where it could be used in every fact module. not a blocker just a crazy idea i had

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filing #10869 for this, and to generalize it. hopefully the lambda + function approach is readable enough for now?

Comment on lines +238 to +239
/// Used by queries that inspect saga state in bulk, like fault-management
/// analysis, where fetching the DAG for every row would be wasteful.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did we check whether there are other places where we might also be doing this, and could benefit from using the smaller type?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this PR, no, but outside this PR: saga_list_running_or_unwinding_older_than and get_all_sagas_in_state could read "not the whole saga row"?

That said, reading #10866 , we might change this implementation after that merges?

I'm not sure. The intent of that PR seems like it's: "try to make it harder to observe invalid saga states, reading from the database (e.g., accessing abandoned metadata in its entirety when it exists) but we are already catching that in the try_from implementation below? Idk

/// A parent-forwarded Saga case, parsed into the form this engine acts on.
/// Every fact on a saga case is about the same `saga_id`, and a case carries
/// at most one fact of each kind.
struct ParsedSagaCase {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ultra nitpicky, but "parsed" feels a bit odd to call something that is really being interpreted from data in the database...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm I used the same naming convention in the disk DE. Would you prefer "InterpretedSagaCase"?

I am taking the DB data, validating it, and shoving it into a new type that signifies the validation. IMO that's parsing. but I can call it something else?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that you've called me out on this, i am not sure if i care about this enough to actually think of an alternative name or not..."parsing" makes me think of "turning bytes/characters into structured data" rather than "turning structured data into more structured data", but I am not sure if I care. this is fine!

Comment thread nexus/fm/src/diagnosis/saga.rs Outdated
Comment on lines +84 to +88
let Some(saga_fact) = fact.payload.as_saga() else {
return Err(UninterpretableCase::ForeignFactPayload {
fact_id: fact.metadata.id,
});
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

occurs to me we might have the various as_$foo() methods return a ForeignFact { fact_id } error of their own, and the UninterpretableCase error could just #[from] ForeignFact here and elsewhere

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll make the as_ methods return Result<_, ForeignFact> and both DEs can use the #[from] magic for conversion. This can also identify which DE the fact actually does belong to.

})
}

pub(super) fn analyze(builder: &mut SitrepBuilder<'_>) -> anyhow::Result<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. I think once we have more than 2 DEs that care about facts, and more than 2 that care about ereports, we might be starting to exit the zone where trying to pull out abstractions is premature!

Comment thread nexus/fm/src/diagnosis/saga.rs Outdated
Comment on lines +135 to +137
// Reference the latest inventory for a concept of "now" when determining
// staleness. This makes analysis deterministic and reproducible in tests.
let reference_time = input.inventory().time_done;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, this is such a good call that I think we maybe ought to enshrine it in convention for other DEs moving forwards --- I had been wondering "hmm I wonder where it will make sense to get a 'now' time for stuff like 'wait at least so long before closing a case'...". Despite the fact that I just wrote something about "premature abstraction" in my last comment, I kinda want to say that we should have an AnalysisInputs::now() or something that just does this in order to encourage future DE code to also do this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd prefer to call this reference_time instead of now, but sure, I can refactor this out now!

Comment on lines +158 to +162
builder
.cases
.case_mut(&case.id)
.expect("case_id came from builder's open cases")
.close(format!("cannot interpret case: {reason}"));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this mayhaps also be a log_warning? since it represents something unexpected rather than just closing the case for a normal reason

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can do that. I'll also update the disk DE for parity.

Comment thread nexus/fm/src/builder.rs Outdated
Comment on lines +263 to +268
let (input, _) = Input::builder(
None,
make_collection(),
Arc::new(IdOrdMap::new()),
Arc::new(IdOrdMap::new()),
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kinda want to make the various maps of queried inputs be added to the builder by methods, so that we don't have to write Arc::new(IdOrdMap::new()) four or five times in every test. but on the other hand it does increase the risk of just forgetting to ever add it, i dunno. on the third hand using a builder method would at least allow the reader to see a name for what that Arc::new(IdOrdMap::new()) is supposed to be...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm taking a swing at this, trying to balance a couple competing interests:

  1. In prod, we really want each of these fields to be supplied explicitly. "Forgetting to set something" is problematic, because an Arc::new(IdOrdMap::new()) has meaning - e.g., for sagas, it means "there aren't any running!". So... I don't want to JUST do setters, since the omission of a callsite could be bad.
  2. We want the names of these fields to be visible at builder call-site.
  3. Under test, it would be nice to not need to re-state all the inputs we aren't using.

Here's my angle:

  • Change the builder to allow these as `Option<...>" by default
  • Inputs are supplied via named setters
  • build() throws an error if inputs are missing
  • Tests can use a with_empty_defaults() function to populate all the fields with a default value (Arc::new(...) here, but can work for whatever).

Comment on lines +381 to +385
vec![
DbMetadataNexusState::Active,
DbMetadataNexusState::NotYet,
DbMetadataNexusState::Quiesced,
],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could perhaps use strum to derive a DbMetadataNexusState::ALL_VARIANTS --- i've found that useful for other DB enum types where you have a query for looking them up by state but also want to sometimes say "don't care"

Comment on lines +376 to +379
// Classify each owning Nexus (current_sec) against db_metadata_nexus.
let nexus_states: BTreeMap<OmicronZoneUuid, DbMetadataNexusState> =
self.datastore
.get_db_metadata_nexus_in_state(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if the map of Nexii and their states will ever want to be a top-level analysis input of its own...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps! I could split this out if we have a DE somewhere else that wants it.


// Inverse index: which parent case is about which saga. Cases are per-saga,
// so a saga with two parent cases is already pathological. We keep one and
// close the rest as duplicates; which one we keep is arbitrary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

part of me wonders if we ought to have a different heuristic for which case to prefer than "which one happened to have the highest UUID" but it's fine I guess...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically it's lowest UUID, because we're iterating through them in Btree order, but...

IDK, I'm personally not super concerned here, I think I just want to prioritize (a) determinism, and (b) graceful handling of impossible states, which is sorta our follow-up goal with slippy

Comment thread nexus/fm/src/diagnosis/saga.rs Outdated
Comment on lines +180 to +187
slog::warn!(
&builder.log,
"closing duplicate Saga case";
"case_id" => %case_id,
"kept_case_id" => %kept_case_id,
"saga_id" => %parsed_case.saga_id,
);
builder

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now that we can log warnings to the sitrep analysis report, let's use that here!

);
logctx.cleanup_successful();
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think in the eventual future, if we have a proptest thing for generating arbitrary inputs, we could maybe help ensure a test like this is interesting by rejecting any inputs where the first analysis step makes a sitrep that has nothing interesting in it, but I dunno. That might run for a long time.

Arc::new(self.load_in_service_disks(opctx, &mut warnings).await?);

let observed_sagas =
Arc::new(self.prepare_observed_sagas(opctx).await?);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idly wondering if this is loading enough stuff that we might want to start running some of these "load thing" steps in parallel. i realize that the way i did ereport loading (which requires stuffing things into the builder) is hard to factor out to run in a child task, but we could change it to return a vec that gets stuffed into the builder or whatever.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed we should do this, and it doesn't seem bad. Added to #10869 for now

Comment on lines +41 to +42
* saga 5a9a0001-5a9a-45a9-85a9-5a9a5a9a5a9a (fake-saga): Unwinding, last event: 1970-01-01 00:00:00 UTC, owner: Quiesced
* saga 5a9a0002-5a9a-45a9-85a9-5a9a5a9a5a9a (another-fake-saga): Abandoned, last event: <none recorded>, owner: <no current SEC>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would kind of prefer it if these were like:

  * saga 5a9a0001-5a9a-45a9-85a9-5a9a5a9a5a9a (fake-saga): 
    state: Unwinding
    last event: 1970-01-01 00:00:00 UTC
    owner: Quiesced
  * saga 5a9a0002-5a9a-45a9-85a9-5a9a5a9a5a9a (another-fake-saga):
    state: Abandoned
    last event: <none recorded>
    owner: <no current SEC>

but i will admit that i'm kind of a fascist about 80-column terminals and you can ignore me

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll patch this up. Happy to be consistent/make this more readable

pub in_service_disks: BTreeSet<PhysicalDiskUuid>,
/// All non-terminal sagas visible to the diagnosis engines for this
/// analysis pass.
pub observed_sagas: BTreeMap<steno::SagaId, ObservedSagaReport>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3 thanks for adding the warning

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a high level question is that, IIRC, there's also an update health check and possibly other stuff that also cares about not-running sagas, might this code be shareable with it? okay to address as a follow up

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So from what I can tell the health check exists in nexus/src/app/update.rs , but it's currently test-only, until #10538 gets fixed.

I do think it would be reasonable for that check to look at: "are there cases open for sagas", or perhaps "are there active problems open for sagas" (if we're trying to consider #10592 (comment) ).

But yeah, I do think it makes sense to de-duplicate these

smklein added 3 commits July 17, 2026 15:22
Main's saga-abandonment-metadata migration (#10602) records when, why,
and with what comment a saga was abandoned. Thread that metadata
through the SagaSummary projection and ObservedSagaState::Abandoned so
the Abandoned fact's case comment can say who abandoned the saga
(saga recovery vs an operator via omdb) and why, instead of the
now-wrong "abandoned by Nexus".

Also fix the semantic fallout of merging main into this branch, which
a textual merge could not catch (and which broke CI):
- SitrepBuilder lost its public log field; use the debug-log API
- open_case now takes the case comment directly
- test_util's input_builder must pass observed_sagas to Input::builder
- the unfinished-sagas test must insert SagaRow, not the validated Saga
- list unfinished sagas via saga_state != 'done' so the query can use
  the lookup_saga_by_sec partial index
- derive DbMetadataNexusState::ALL with strum::VariantArray
- tighten the fm_fact_saga not_progressing CHECK to saga_state IN
  ('running', 'unwinding')
- construct InternalError directly instead of internal_error(&format!)
- include both colliding payloads in the duplicate-fact-UUID error, and
  add fact/case IDs as internal context when into_fact fails
- Fact::as_saga/as_physical_disk return a shared ForeignFact error that
  both DEs' UninterpretableCase wraps via #[from]
- add Input::reference_time() as the deterministic "now" for DEs
- log a warning to the analysis report when closing uninterpretable
  cases (saga and disk DEs)
- analysis Input builder: required named setters for queried inputs,
  plus a test-only with_empty_defaults()
- multi-line per-saga format in the input report display
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fm] File a case, create active problem for "sagas which are stuck"

3 participants