Settle: live-PG pins (lost race writes no audit row, audit fault rolls back, concurrent settles once); SettleableOutcomes read-only; docs: a settle does not fence an in-flight replay
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
870b475795
commit
6e705852fb
3 changed files with 85 additions and 2 deletions
|
|
@ -79,7 +79,7 @@ Two actions (`heimdall-body:ruling:merge-stuck-end-only-typed-id`), both `Accoun
|
|||
| Fail the merge (`FailForward`) | Confirmed, stuck | `Failed`, `FailureCode` and `LastOutcome` `operator_failed`, proofs wiped. The folded account is free again; the pair can be confirmed anew. |
|
||||
| Give up the undo (`AbandonUndo`) | UndoRequested, stuck | back to `Committed`, `LastOutcome` `undo_operator_abandoned`, proofs wiped, the grace as it was. The merge stands; the logins stay with the survivor. |
|
||||
|
||||
HeimdallMoved and Committed are never settleable: Heimdall answered Moved with a matching receipt there, and the saga rolls forward. A settle and the sweeper's `AdvanceAsync` meet at the `State` concurrency token; the loser changes nothing and the other transition stands (answered `lost`).
|
||||
HeimdallMoved and Committed are never settleable: Heimdall answered Moved with a matching receipt there, and the saga rolls forward. A settle and the sweeper's `AdvanceAsync` meet at the `State` concurrency token; the loser changes nothing and the other transition stands (answered `lost`). A settle does not fence a replay already in flight to Heimdall, so read `identity_moves` first and read it again after.
|
||||
|
||||
Before ending a merge, read Heimdall's `identity_moves` row by the stored move id. If Heimdall did move the logins, failing the merge leaves them moved with no Bifrost merge to match: repair or reverse at Heimdall first. For `receipt_mismatch`, repair Heimdall's or Bifrost's account link before failing. Adopting a move Heimdall made (writing HeimdallMoved from a receipt) stays manual SQL until follow-up `merge-operator-adopt-moved`; the page never does it.
|
||||
|
||||
|
|
|
|||
|
|
@ -432,7 +432,7 @@ public sealed partial class AccountMergeSaga(
|
|||
// ---- operator settle ----
|
||||
|
||||
/// <summary>The Heimdall answers no replay can change: the one the saga recorded and stopped on, the key ring's loss, and a move Heimdall holds but will not confirm.</summary>
|
||||
public static readonly string[] SettleableOutcomes = [ReceiptMismatch, ProofsUnreadable, MoveIdConflict];
|
||||
public static readonly IReadOnlyList<string> SettleableOutcomes = [ReceiptMismatch, ProofsUnreadable, MoveIdConflict];
|
||||
|
||||
/// <summary>
|
||||
/// Whether an operator may end this merge's leg: in flight (Confirmed or UndoRequested) and stopped on one of the closed
|
||||
|
|
|
|||
|
|
@ -865,6 +865,89 @@ public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFi
|
|||
Assert.True(blocked);
|
||||
}
|
||||
|
||||
/// <summary>A Confirmed merge with sealed proofs, stopped on the given outcome, and the operator who will settle it.</summary>
|
||||
private async Task<(AccountMergeLedgerTests.World World, Heimdall Heimdall, Guid Id, string MoveId, Guid Operator)> StuckConfirmedAsync(string outcome)
|
||||
{
|
||||
var world = await postgres.NewWorldAsync();
|
||||
var survivor = await world.AccountAsync("survivor");
|
||||
var folded = await world.AccountAsync("folded");
|
||||
var operatorId = (await world.AccountAsync("operator")).Id;
|
||||
var heimdall = new Heimdall(world);
|
||||
Guid id;
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id);
|
||||
id = Assert.IsType<ConfirmOutcome.Confirmed>(await heimdall.Saga(world, db).ConfirmAsync(
|
||||
new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, heimdall.Proof(survivor, "twitch"), [heimdall.Proof(folded, "discord")]), default)).MergeId;
|
||||
}
|
||||
|
||||
await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'Confirmed', \"LastOutcome\" = '{outcome}' WHERE \"Id\" = '{id}'");
|
||||
await using var read = world.Db();
|
||||
var merge = await read.AccountMerges.AsNoTracking().SingleAsync();
|
||||
Assert.NotNull(merge.SealedProofs);
|
||||
return (world, heimdall, id, merge.HeimdallMoveId!, operatorId);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_settle_that_loses_to_a_rival_state_write_inside_its_save_window_writes_nothing_and_no_audit_row()
|
||||
{
|
||||
var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("move_id_conflict");
|
||||
var settler = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(() =>
|
||||
ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'HeimdallMoved' WHERE \"Id\" = '{id}'")));
|
||||
await using (var db = settler.Db())
|
||||
{
|
||||
Assert.IsType<SettleOutcome.Lost>(await heimdall.Saga(settler, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default));
|
||||
}
|
||||
|
||||
await using var read = world.Db();
|
||||
var merge = await read.AccountMerges.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(AccountMergeState.HeimdallMoved, merge.State);
|
||||
Assert.NotNull(merge.SealedProofs);
|
||||
Assert.Equal("move_id_conflict", merge.LastOutcome);
|
||||
Assert.Equal(0, await read.AuditEvents.CountAsync());
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task A_settle_whose_audit_insert_fails_rolls_the_transition_back()
|
||||
{
|
||||
var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("receipt_mismatch");
|
||||
await ExecuteAsync(world, """
|
||||
CREATE FUNCTION fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected audit fault'; END $$;
|
||||
CREATE TRIGGER fail_audit BEFORE INSERT ON "AuditEvents" FOR EACH ROW EXECUTE FUNCTION fail_audit();
|
||||
""");
|
||||
await using (var db = world.Db())
|
||||
{
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => heimdall.Saga(world, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default));
|
||||
}
|
||||
|
||||
await using var read = world.Db();
|
||||
var merge = await read.AccountMerges.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(AccountMergeState.Confirmed, merge.State);
|
||||
Assert.NotNull(merge.SealedProofs);
|
||||
Assert.Equal("receipt_mismatch", merge.LastOutcome);
|
||||
Assert.Equal(0, await read.AuditEvents.CountAsync());
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task Two_concurrent_settles_of_one_stuck_merge_settle_once_lose_once_and_audit_once()
|
||||
{
|
||||
var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("proofs_unreadable");
|
||||
var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous());
|
||||
async Task<SettleOutcome> Settle()
|
||||
{
|
||||
await using var db = racers.Db();
|
||||
return await heimdall.Saga(racers, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default);
|
||||
}
|
||||
|
||||
var both = await Task.WhenAll(Task.Run(Settle), Task.Run(Settle));
|
||||
|
||||
Assert.Single(both.OfType<SettleOutcome.Settled>());
|
||||
Assert.Single(both.OfType<SettleOutcome.Lost>());
|
||||
await using var read = world.Db();
|
||||
Assert.Equal(1, await read.AuditEvents.CountAsync());
|
||||
Assert.Equal(AccountMergeState.Failed, (await read.AccountMerges.AsNoTracking().SingleAsync()).State);
|
||||
}
|
||||
|
||||
[PostgresFact]
|
||||
public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue