Skip to content

Commit 03e23f0

Browse files
committed
tapgarden: reorder Confirmed branch so MarkBatchConfirmed writes last
BatchStateConfirmed on main names two different essences with the same word: an *input state* for the Confirmed branch of the state machine, and a *mid-branch durable checkpoint* written by MarkBatchConfirmed. The Confirmed branch published universe proofs, emitted the supply-commit event, called MarkBatchConfirmed (which advanced disk state to Confirmed), and then registered the anchor tx with the re-org watcher via WatchProofs. A crash between MarkBatchConfirmed and WatchProofs left disk at Confirmed with no re-org callback registered by us; on restart the fast-forward skipped the branch entirely, and the re-org watcher's own Start- time recovery re-registered the anchor tx with its DefaultUpdateCallback -- silently dropping the universe re-publish that updateMintingProofs performs on any subsequent re-org. Dissolve the identity conflation by making MarkBatchConfirmed the LAST persistence write in the branch. WatchProofs now runs first; disk-Confirmed genuinely means "every step the Confirmed branch owes is done." A crash before MarkBatchConfirmed keeps the batch at Broadcast so the whole branch re-runs on restart -- universe publish is idempotent, augmenter side is deduped by event_key (migration 62), storeMintingProof rides upserts, and WatchProofs against a fresh watcher instance is a first-time registration. Also rewrite the surrounding comments to describe the new ordering, note the semantic justification for the restart fast- forward, and drop the stale TODO on the Finalized case ("confirmed should just be the final state?") -- that concern is fully resolved by the new ordering. Add a ShouldFail atomic.Bool hook to MockProofWatcher and a testWatchProofsFailureAbortsConfirmation test that forces WatchProofs to fail, asserts the batch stays at Broadcast (under the old ordering this assertion would fail because MarkBatchConfirmed would have run before the WatchProofs failure), restarts with the mock succeeding, and asserts the batch advances to Finalized. Pins the ordering invariant against regression.
1 parent 50ef886 commit 03e23f0

3 files changed

Lines changed: 143 additions & 14 deletions

File tree

tapgarden/cultivator.go

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,12 @@ func (b *Cultivator) assetCultivator() {
388388
defer b.Wg.Done()
389389

390390
currentBatchState := b.cfg.Batch.State()
391-
// If the batch is already marked as confirmed, then we just need to
392-
// advance it one more level to be finalized.
391+
// If the batch is already marked as confirmed, then we just
392+
// need to advance it one more level to be finalized. Under the
393+
// current Confirmed-branch ordering, MarkBatchConfirmed is the
394+
// last persistence write, so disk-Confirmed means the full
395+
// Confirmed branch (including the re-org watcher registration)
396+
// already ran to completion; the skip is semantically justified.
393397
if currentBatchState == BatchStateConfirmed {
394398
log.Infof("MintingBatch(%x): already confirmed!", b.batchKey[:])
395399

@@ -1231,9 +1235,8 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) {
12311235
// confirmation branch re-runs on restart: universe
12321236
// publish above is idempotent, the event_key dedup
12331237
// index (migration 62, backfilled by 63) makes the
1234-
// augmenter side idempotent, and MarkBatchConfirmed
1235-
// below is what advances state on disk -- so retry is
1236-
// safe.
1238+
// augmenter side idempotent, and MarkBatchConfirmed is
1239+
// the last write below -- so retry is safe.
12371240
err = b.augmenter().OnBatchConfirmed(
12381241
ctx, b.cfg.Batch, anchorAssets, nonAnchorAssets,
12391242
mintingProofs,
@@ -1243,6 +1246,35 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) {
12431246
err)
12441247
}
12451248

1249+
// Register the batch's proofs with the re-org watcher
1250+
// before advancing state on disk. If this fails, the
1251+
// batch stays in BatchStateBroadcast and the whole
1252+
// confirmation branch re-runs on restart, ensuring the
1253+
// correct updateMintingProofs callback is the one bound
1254+
// to the anchor tx. If we instead registered after
1255+
// MarkBatchConfirmed, a crash between the two would
1256+
// leave disk at Confirmed with no callback registered by
1257+
// us; the re-org watcher's Start-time recovery would
1258+
// re-register with its DefaultUpdateCallback, silently
1259+
// dropping the universe re-publish on any subsequent
1260+
// re-org.
1261+
if err := b.cfg.ProofWatcher.WatchProofs(
1262+
maps.Values(mintingProofs), b.cfg.UpdateMintingProofs,
1263+
); err != nil {
1264+
return 0, fmt.Errorf("error watching proof: %w", err)
1265+
}
1266+
1267+
// MarkBatchConfirmed is the last persistence write in
1268+
// this branch. Under this ordering, disk-Confirmed
1269+
// genuinely means "every step this branch owes is done."
1270+
// A crash before this call keeps the batch at
1271+
// BatchStateBroadcast and the branch re-runs on restart;
1272+
// a crash after has nothing left to do beyond the
1273+
// terminal Finalized state advance. The essence-splitting
1274+
// that produced the older reorg-callback gap (disk-
1275+
// Confirmed as both an input state and a mid-branch
1276+
// checkpoint) is dissolved by making the on-disk name
1277+
// mean what it says.
12461278
err = b.cfg.BatchStore.MarkBatchConfirmed(
12471279
ctx, b.cfg.Batch, confInfo.BlockHash,
12481280
confInfo.BlockHeight, confInfo.TxIndex,
@@ -1252,14 +1284,6 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) {
12521284
return 0, fmt.Errorf("unable to confirm batch: %w", err)
12531285
}
12541286

1255-
// Now that we've confirmed the batch, we'll hand over the
1256-
// proofs to the re-org watcher.
1257-
if err := b.cfg.ProofWatcher.WatchProofs(
1258-
maps.Values(mintingProofs), b.cfg.UpdateMintingProofs,
1259-
); err != nil {
1260-
return 0, fmt.Errorf("error watching proof: %w", err)
1261-
}
1262-
12631287
log.Infof("Cultivator(%x): transition states: %v -> %v",
12641288
b.batchKey[:], BatchStateConfirmed, BatchStateFinalized)
12651289

@@ -1271,7 +1295,6 @@ func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) {
12711295
log.Infof("Cultivator(%x): transition states: %v -> %v",
12721296
b.batchKey[:], BatchStateFinalized, BatchStateFinalized)
12731297

1274-
// TODO(roasbeef): confirmed should just be the final state?
12751298
ctx, cancel := b.WithCtxQuit()
12761299
defer cancel()
12771300
err := b.cfg.BatchStore.UpdateBatchState(

tapgarden/mock.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,11 +571,21 @@ func (m *MockProofArchive) ImportProofs(context.Context,
571571
}
572572

573573
type MockProofWatcher struct {
574+
// ShouldFail, when set, causes WatchProofs to return an error
575+
// instead of the usual no-op. Tests that need to observe the
576+
// caretaker's handling of a re-org watcher registration failure
577+
// flip this on before driving the relevant state transition and
578+
// off before the retry.
579+
ShouldFail atomic.Bool
574580
}
575581

576582
func (m *MockProofWatcher) WatchProofs([]*proof.Proof,
577583
proof.UpdateCallback) error {
578584

585+
if m.ShouldFail.Load() {
586+
return fmt.Errorf("simulated re-org watcher failure")
587+
}
588+
579589
return nil
580590
}
581591

tapgarden/planter_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2302,6 +2302,98 @@ func testOnBatchConfirmedFailureAbortsConfirmation(t *mintingTestHarness) {
23022302
t.assertNumCaretakersActive(0)
23032303
}
23042304

2305+
// testWatchProofsFailureAbortsConfirmation asserts that a failure
2306+
// returned from ProofWatcher.WatchProofs aborts mint confirmation:
2307+
// the batch stays in BatchStateBroadcast on disk so the whole
2308+
// confirmation branch re-runs on restart. This pins the ordering
2309+
// invariant that MarkBatchConfirmed is the LAST persistence write
2310+
// in the Confirmed branch -- under the pre-A2 ordering
2311+
// (MarkBatchConfirmed before WatchProofs), a WatchProofs failure
2312+
// would leave the batch at BatchStateConfirmed and this assertion
2313+
// would fail. The invariant matters because a batch at disk-
2314+
// Confirmed with no registered re-org callback silently drops
2315+
// universe re-publish on subsequent re-orgs.
2316+
func testWatchProofsFailureAbortsConfirmation(t *mintingTestHarness) {
2317+
// Wire the harness so the mock re-org watcher rejects the
2318+
// registration on the first pass; the retry after restart
2319+
// receives a fresh MockProofWatcher (zero ShouldFail) and
2320+
// succeeds.
2321+
t.proofWatcher.ShouldFail.Store(true)
2322+
t.refreshChainPlanter()
2323+
2324+
// Drive Pending -> Frozen -> Committed -> Broadcast.
2325+
const numSeedlings = 3
2326+
_ = t.queueInitialBatch(numSeedlings)
2327+
frozenBatch := t.finalizeBatchAssertFrozen(false)
2328+
t.assertBatchCommitted(frozenBatch.BatchKey.PubKey)
2329+
t.assertGenesisPsbtFinalized(nil)
2330+
tx := t.assertTxPublished()
2331+
2332+
// Assemble the confirmation block.
2333+
merkleTree := blockchain.BuildMerkleTreeStore(
2334+
[]*btcutil.Tx{btcutil.NewTx(tx)}, false,
2335+
)
2336+
merkleRoot := merkleTree[len(merkleTree)-1]
2337+
blockHeader := wire.NewBlockHeader(
2338+
0, chaincfg.MainNetParams.GenesisHash, merkleRoot, 0, 0,
2339+
)
2340+
block := &wire.MsgBlock{
2341+
Header: *blockHeader,
2342+
Transactions: []*wire.MsgTx{tx},
2343+
}
2344+
2345+
// Deliver the confirmation. WatchProofs will fail and the
2346+
// Confirmed branch must abort before MarkBatchConfirmed.
2347+
sendConfNtfn := t.assertConfReqSent(tx, block)
2348+
sendConfNtfn()
2349+
2350+
// The batch on disk must remain at BatchStateBroadcast. If A2's
2351+
// ordering regressed and MarkBatchConfirmed ran before the
2352+
// WatchProofs failure, we'd see BatchStateConfirmed here
2353+
// instead.
2354+
require.Never(t, func() bool {
2355+
batches, err := t.store.FetchAllBatches(context.Background())
2356+
require.NoError(t, err)
2357+
if len(batches) != 1 {
2358+
return false
2359+
}
2360+
return batches[0].State() != tapgarden.BatchStateBroadcast
2361+
}, 500*time.Millisecond, 50*time.Millisecond,
2362+
"batch advanced past Broadcast despite WatchProofs failure")
2363+
2364+
// Simulate a restart with a now-succeeding re-org watcher. The
2365+
// fresh caretaker resumes the Broadcast batch, re-publishes the
2366+
// tx, and re-registers for a confirmation.
2367+
t.proofWatcher.ShouldFail.Store(false)
2368+
t.refreshChainPlanter()
2369+
select {
2370+
case <-t.errChan:
2371+
default:
2372+
}
2373+
2374+
_ = t.assertTxPublished()
2375+
sendConfNtfn = t.assertConfReqSent(tx, block)
2376+
sendConfNtfn()
2377+
2378+
// With WatchProofs now returning nil, the Confirmed branch
2379+
// completes and the batch advances to Finalized.
2380+
err := wait.Predicate(func() bool {
2381+
batches, err := t.store.FetchAllBatches(
2382+
context.Background(),
2383+
)
2384+
require.NoError(t, err)
2385+
if len(batches) != 1 {
2386+
return false
2387+
}
2388+
return batches[0].State() == tapgarden.BatchStateFinalized
2389+
}, defaultTimeout)
2390+
require.NoError(
2391+
t, err, "batch never advanced to Finalized on retry",
2392+
)
2393+
2394+
t.assertNumCaretakersActive(0)
2395+
}
2396+
23052397
// mintingStoreTestCase is used to programmatically run a series of test cases
23062398
// that are parametrized based on a fresh minting store.
23072399
type mintingStoreTestCase struct {
@@ -2351,6 +2443,10 @@ var testCases = []mintingStoreTestCase{
23512443
name: "on_batch_confirmed_failure_aborts_confirmation",
23522444
testFunc: testOnBatchConfirmedFailureAbortsConfirmation,
23532445
},
2446+
{
2447+
name: "watch_proofs_failure_aborts_confirmation",
2448+
testFunc: testWatchProofsFailureAbortsConfirmation,
2449+
},
23542450
}
23552451

23562452
// TestBatchedAssetIssuance runs a test of tests to ensure that the set of

0 commit comments

Comments
 (0)