[saga-recovery] Mark sagas with non-transient errors as abandoned#10602
Conversation
| // We can't abandon a saga whose current SEC is unknown (there's no row to | ||
| // conditionally update on), so a permanent failure with no `current_sec` is | ||
| // left as a recovery candidate. |
There was a problem hiding this comment.
This kind of sucks? I've left it like this due to the comment in the saga_update_state() method
/// It's conceivable that multiple SECs do try to udpate the same saga
/// concurrently. That would be a bug. This is noticed and prevented by
/// making this query conditional on current_sec and failing with a conflict
/// if the current SEC has changed.
There was a problem hiding this comment.
For what it's worth, I believe this cannot be None. I think the schema allowed for that because we thought we might use it to deal with failover, but I don't think we ever did.
I don't think you should do anything different here, except maybe make it an error instead of a warn and add that to the comment.
|
To avoid annoying merge conflicts due to versioning, I'm not merging with main until the PR is approved. |
| // We can't abandon a saga whose current SEC is unknown (there's no row to | ||
| // conditionally update on), so a permanent failure with no `current_sec` is | ||
| // left as a recovery candidate. |
There was a problem hiding this comment.
For what it's worth, I believe this cannot be None. I think the schema allowed for that because we thought we might use it to deal with failover, but I don't think we ever did.
I don't think you should do anything different here, except maybe make it an error instead of a warn and add that to the comment.
|
(Feel free to ignore this.) I tried the Claude skill from #10125 on it and got the following report. One of these (the full-struct destructuring one) is totally bogus, one is meaningless, and the other two are valid ones that I mentioned above. I did like the way it phrased it as: we made the write side strongly type-safe but the read side isn't yet. Type Safety ReviewModeBranch diff from merge-base 13937a1 (main) to HEAD (mark-failed-sagas-as-abandoned). Files reviewed:
Overall, this branch uses the type system well. FindingsSUGGESTION — Multiple representations / sentinel values — nexus/db-queries/src/db/datastore/saga.rs:31 and dev-tools/omdb/src/bin/omdb/db/saga.rs:441Problem: == Abandoned". The omdb let new_state = SagaStateUpdate {
saga_state: SagaState::Abandoned,
abandon_reason: Some(nexus_db_model::SagaReasonAbandoned::Omdb),
abandon_information: Some(information),
abandon_time: Some(Utc::now()),
};Nothing in the type system prevents someone from writing, for example, Fix: Make let new_state = SagaStateTransition::Abandoned {
reason: SagaReasonAbandoned::Omdb,
information,
}
.into_update();
diesel::update(dsl::saga)
.filter(dsl::id.eq(saga_id))
.set(new_state)
.execute_async(&*conn)
.await?;Now every path that produces a SUGGESTION — Missing full-struct destructuring in serialization — nexus/db-queries/src/db/datastore/saga.rs:31Problem: Fix: Not strictly necessary today, but consider defining SUGGESTION — Multiple representations / sentinel values — nexus/db-model/src/saga_types.rs:231-238Problem: The pub abandon_time: Option<chrono::DateTime<chrono::Utc>>,
pub abandon_reason: Option<SagaReasonAbandoned>,
pub abandon_information: Option<String>,Every read-side consumer that wants to describe the abandonment must independently pattern-match three The fix on the write path (SagaStateTransition) is only half the story: the read side has the same shape it always did. Fix: Consider adding a read-side accessor that groups the tuple: pub struct AbandonInfo {
pub time: DateTime<Utc>,
pub reason: SagaReasonAbandoned,
pub information: String,
}
impl Saga {
/// Returns abandonment metadata if the saga is abandoned.
pub fn abandon_info(&self) -> Option<AbandonInfo> {
match (self.abandon_time, self.abandon_reason, self.abandon_information.as_ref()) {
(Some(time), Some(reason), Some(information)) => {
Some(AbandonInfo { time, reason, information: information.clone() })
}
(None, None, None) => None,
_ => None, // DB CHECK should prevent this; consider logging or returning Result
}
}
}The three raw fields can stay for diesel, but consumers should be encouraged (or required, if the fields were made This is a lower-priority suggestion than the first because the SUGGESTION — Weak enum / bool usage — nexus/db-queries/src/db/datastore/saga.rs:50-61Problem: In SagaStateTransition::Abandoned { reason: _, information: _ } => {
SagaState::Abandoned
}That is already explicit and will error if a new field is added to Not a finding — noted because it's easy to accidentally break by using No issues found in:
Summary0 blocking issues, 3 suggestions (plus one non-finding note). The strongest opportunity is closing the |
karencfv
left a comment
There was a problem hiding this comment.
Thanks for the review @davepacheco! I think I've addressed all of your comments. The new SagaRow implementation feels a little wonky, but does the job! Let me know what you think
| abandon_reason omicron.public.saga_abandon_reason, | ||
| abandon_comment TEXT, |
There was a problem hiding this comment.
Thanks for adding these. Either in #10592 or a follow-up, I can propagate this info to the FM system, which can highlight this info for operators.
There was a problem hiding this comment.
Sweet! Does this format work for you? Or is there a better way to represent these for FM?
There was a problem hiding this comment.
This format is fine; we're scooping stuff up now based on the saga_state, and it's easy to grab an auxiliary data hanging off the rest of the row here too
| let row = dsl::saga | ||
| .filter(dsl::id.eq(args.saga_id)) | ||
| .select(nexus_db_model::SagaRow::as_select()) | ||
| .first_async(&*conn) | ||
| .await? | ||
| .await?; | ||
| Saga::try_from(row)? |
There was a problem hiding this comment.
I didn't realize the degree to which we already had code outside of the DataStore that was querying for sagas directly with diesel. In an ideal world, we wouldn't expose SagaRow at all outside the datastore and callers wouldn't have to think about this conversion.
This makes me think it might be worth looking into a better way to encapsulate the model type at the diesel layer (e.g., is there some trait we could impl like Selectable that would let us just do this select as-is and do the (fallible) load that validates the metadata?). But that's probably beyond the scope of this PR.
There was a problem hiding this comment.
Yeah, it was really annoying when I discovered that as well :( I can write up a follow up issue though!
| prompt.read_and_validate("y/N", "y")?; | ||
| drop(prompt); | ||
|
|
||
| let new_state = SagaStateDbFields { |
There was a problem hiding this comment.
It would seem a bit safer to use datastore.saga_update_state() here. I think that would let you make SagaStateDbFields non-pub.
There was a problem hiding this comment.
I tried doing this before, but it's not really possible to do this and keep the same functionality of the command. I would have to completely modify the logic around finding the current_sec (bypass_sec_check flag functionality), and this seems out of scope for this PR. I can write up an issue for a follow up PR though!
Semi-unrelated, how disruptive do you think it would be to make the current_sec field not be an option (not as part of this PR of course!)?
There was a problem hiding this comment.
Semi-unrelated, how disruptive do you think it would be to make the current_sec field not be an option (not as part of this PR of course!)?
I'd like to do that, too, and I think it ought to be fine, but we don't really have a way of dealing with migrations like that that fail. e.g., if there was some saga out there with current_sec IS NULL, the obvious schema migration would just fail and I think the update would get stuck in a bad place. Make we could do something where we install a sentinel value for any such rows?
|
Thanks, this is getting there! |
|
Thanks for the thorough review @davepacheco! I think this should be ready to go now 🤞 I'll merge main and fix merge conflicts once I get the ✅ to avoid fixing merge conflicts more than once |
davepacheco
left a comment
There was a problem hiding this comment.
Thanks for the changes. Just a few very small nits here that don't need another round of review.
| /// read via [`Saga::abandon_metadata`]. Reads go through `TryFrom<SagaRow>`, | ||
| /// which rejects rows whose metadata is inconsistent with `saga_state` with an | ||
| /// [`Error::internal_error`]. | ||
| /// into a single private `abandon` field, kept all or none. Reads go through |
There was a problem hiding this comment.
| /// into a single private `abandon` field, kept all or none. Reads go through | |
| /// into a single `abandon` field, kept all or none. Reads go through |
| if saga_state == SagaState::Abandoned { | ||
| Some(abandon_metadata.clone()) | ||
| } else { | ||
| _ => { |
There was a problem hiding this comment.
nit: I think we generally prefer avoiding these wildcards (listing out the specific variants instead)
| prompt.read_and_validate("y/N", "y")?; | ||
| drop(prompt); | ||
|
|
||
| let new_state = SagaStateDbFields { |
There was a problem hiding this comment.
Semi-unrelated, how disruptive do you think it would be to make the current_sec field not be an option (not as part of this PR of course!)?
I'd like to do that, too, and I think it ought to be fine, but we don't really have a way of dealing with migrations like that that fail. e.g., if there was some saga out there with current_sec IS NULL, the obvious schema migration would just fail and I think the update would get stuck in a bad place. Make we could do something where we install a sentinel value for any such rows?
| match Saga::try_from(row.clone()) { | ||
| Ok(saga_row) => sagas.push(saga_row), | ||
| Err(e) => { | ||
| println!("WARNING: Skipping saga with id {}: {e}", row.id()) |
There was a problem hiding this comment.
| println!("WARNING: Skipping saga with id {}: {e}", row.id()) | |
| eprintln!("WARNING: Skipping saga with id {}: {e}", row.id()) |
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
Closes: #10581