[fm] Add saga diagnosis engine#10592
Conversation
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.
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.
90185bb to
5b48c08
Compare
b5f8ee1 to
dffc15c
Compare
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.
- 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
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()) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
Do you need #[serde(default)] here to make preexisting serialized instances parseable?
There was a problem hiding this comment.
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)
| ); | ||
| logctx.cleanup_successful(); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| if reference_time.signed_duration_since(last_progress) | ||
| > STALE_SAGA_THRESHOLD |
There was a problem hiding this comment.
Nit: the comment at the top of the file describing STALE_SAGA_THRESHOLD says "at least this long", but this is strict greater-than.
| Some(DbMetadataNexusState::Quiesced) => { | ||
| SagaOwnerState::Quiesced | ||
| } | ||
| None => SagaOwnerState::Absent, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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<()> { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_nexusrow 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.
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)) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
There is an index to catch this:
omicron/schema/crdb/dbinit.sql
Lines 2994 to 3000 in 007c97f
I can phrase this as the inverse (saga_state.ne(done)) to match the index in a more obvious way?
| vec![ | ||
| DbMetadataNexusState::Active, | ||
| DbMetadataNexusState::NotYet, | ||
| DbMetadataNexusState::Quiesced, | ||
| ], |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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"
There was a problem hiding this comment.
Yeah, strum seems reasonable. I'll use the "all variants" approach here.
| -- 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>, | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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".
| /// 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. |
There was a problem hiding this comment.
this comment feels a bit like claude gibberish, could we maybe rewrite this in a way that's a bit easier to read?
There was a problem hiding this comment.
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".
| Err(Error::internal_error(&format!( | ||
| "fm_fact_saga row has saga_state {state:?}, which is never \ | ||
| recorded on a NotProgressing saga fact" | ||
| ))) |
There was a problem hiding this comment.
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:
| 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...
There was a problem hiding this comment.
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.
| self.saga_state.ok_or_else(|| { | ||
| missing_column(kind, "saga_state") | ||
| })?, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Filing #10869 for this, and to generalize it. hopefully the lambda + function approach is readable enough for now?
| /// Used by queries that inspect saga state in bulk, like fault-management | ||
| /// analysis, where fetching the DAG for every row would be wasteful. |
There was a problem hiding this comment.
did we check whether there are other places where we might also be doing this, and could benefit from using the smaller type?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
ultra nitpicky, but "parsed" feels a bit odd to call something that is really being interpreted from data in the database...
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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!
| let Some(saga_fact) = fact.payload.as_saga() else { | ||
| return Err(UninterpretableCase::ForeignFactPayload { | ||
| fact_id: fact.metadata.id, | ||
| }); | ||
| }; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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<()> { |
There was a problem hiding this comment.
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!
| // 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I think I'd prefer to call this reference_time instead of now, but sure, I can refactor this out now!
| builder | ||
| .cases | ||
| .case_mut(&case.id) | ||
| .expect("case_id came from builder's open cases") | ||
| .close(format!("cannot interpret case: {reason}")); |
There was a problem hiding this comment.
should this mayhaps also be a log_warning? since it represents something unexpected rather than just closing the case for a normal reason
There was a problem hiding this comment.
Sure, I can do that. I'll also update the disk DE for parity.
| let (input, _) = Input::builder( | ||
| None, | ||
| make_collection(), | ||
| Arc::new(IdOrdMap::new()), | ||
| Arc::new(IdOrdMap::new()), | ||
| ) |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
I'm taking a swing at this, trying to balance a couple competing interests:
- 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. - We want the names of these fields to be visible at builder call-site.
- 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).
| vec![ | ||
| DbMetadataNexusState::Active, | ||
| DbMetadataNexusState::NotYet, | ||
| DbMetadataNexusState::Quiesced, | ||
| ], |
There was a problem hiding this comment.
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"
| // Classify each owning Nexus (current_sec) against db_metadata_nexus. | ||
| let nexus_states: BTreeMap<OmicronZoneUuid, DbMetadataNexusState> = | ||
| self.datastore | ||
| .get_db_metadata_nexus_in_state( |
There was a problem hiding this comment.
i wonder if the map of Nexii and their states will ever want to be a top-level analysis input of its own...
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
now that we can log warnings to the sitrep analysis report, let's use that here!
| ); | ||
| logctx.cleanup_successful(); | ||
| } | ||
| } |
There was a problem hiding this comment.
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?); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed we should do this, and it doesn't seem bad. Added to #10869 for now
| * 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> |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
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
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 thanSTALE_SAGA_THRESHOLD(this is 30 minutes, but we can set it to whatever).OwnerNotCurrentGeneration: the saga'scurrent_secis a Nexus that will never advance it, either quiesced (an older generation that handed off) or expunged (nodb_metadata_nexusrecord).Abandoned: Nexus permanently gave up on recovering the saga.Inputs
The preparation phase builds an
ObservedSagaprojection, the saga analog of #10541'sInServiceDisk: all unfinished sagas (running, unwinding, or abandoned), joined with each saga's latest node-event time and its owner's classification againstdb_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
Abandonedfact, until remediation removes the saga row. Actually remediating anAbandonedsaga seems like a case-by-case resolution, so I'm not doing it in this PR!Context
Abandonedfact kind exists so that landing Nexus should explicitly abandon sagas that it fails to recover for non-transient reasons #10581 escalates these cases instead of silently closing them.Fixes #10530