Skip to content

batchcanon: BatchCanonicalityManager (C3/C4) - #795

Closed
ellemouton wants to merge 2 commits into
c2-batch-canonicality-data-modelfrom
c3-batch-canonicality-manager
Closed

batchcanon: BatchCanonicalityManager (C3/C4)#795
ellemouton wants to merge 2 commits into
c2-batch-canonicality-data-modelfrom
c3-batch-canonicality-manager

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Summary

Task C3/C4 of the reorg-safety epic (lightninglabs/darepo#454): the BatchCanonicalityManager — the sole client-side interpreter of batch canonicality. It sits above #422's reorg-aware chainsource and consumes the C2 data model (#794), turning raw chain observation into the durable batchcanon.State. Stacked on #794 (C2).

What it does

  • One reorg-aware confirmation watch per batch tx + one reorg-aware spend watch per consumed input (deduped per batch; registration is idempotent — repeats merge dependent VTXOs).
  • Re-wraps chainsource ConfirmationEvent/ConfReorgedEvent/ConfDoneEvent and SpendEvent/SpendReorgedEvent/SpendDoneEvent onto its own mailbox and derives State by the priority conflict_finalized > conflict_provisional > reorged_out > finalized/provisional > unseen.
  • Confirmed → provisional; chainsource Done → finalized (the manager treats Done as the policy-finality signal, no depth math); ConfReorged → reorged_out with the confirmation cleared, so the derived effective expiry is erased and recomputed on reconfirmation.
  • A consumed input spent by a tx other than the batch is a conflict (the batch spending its own input is the expected, non-conflicting case); SpendDone → conflict_finalized; SpendReorged clears it.
  • Reconcile re-arms watches for non-final batches on restart, seeding in-memory state from the persisted record so re-observation never transiently downgrades a persisted state.
  • Persists the confirmation pkScript (new migration 000021) so restart can re-register the conf watch on light-client backends.

Out of scope (intentionally)

  • No admission/rejection — the VTXO manager admission gate is C5.
  • Spend watches are registered by outpoint (sufficient for the full-node/LND path); per-input pkScripts for light-client backends arrive when the producers (round/OOR, C6/C7) wire in — they hold those scripts.

Tests (reorg safety, all run + race-clean)

A mock chainsource drives the manager through: confirm→finalize; reorg-out → reconfirm (with effective-expiry recompute); input conflict → conflict_provisional → conflict_finalized; conflict cleared on spend reorg; batch self-spend is not a conflict; conflict dominates reorged_out (state priority); spend-watch release on finalize; idempotent re-register merging dependents; and restart reconcile re-arming watches without downgrading persisted state.

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the Manager actor to interpret chain observations into canonicality states, along with a database migration (version 21) to persist the confirmation_pk_script for rebuilding watches upon restart. Feedback on the implementation highlights three critical issues: a data race in the Reconcile method due to map mutations outside the actor's receive loop, a memory and actor leak caused by not pruning finalized batches or unregistering their watches, and a race condition during restart reconciliation that can transiently downgrade conflict states to provisional.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread batchcanon/manager.go
Comment on lines +618 to +638
func (m *Manager) Reconcile(ctx context.Context) error {
// Non-final states whose watches must be re-armed. Finalized and
// conflict_finalized batches need no further watching.
live := []State{
StateUnseen, StateProvisional, StateReorgedOut,
StateConflictProvisional,
}

for _, state := range live {
records, err := m.cfg.Store.ListBatchesByState(ctx, state)
if err != nil {
return fmt.Errorf("list %s batches: %w", state, err)
}

for _, record := range records {
m.reconcileOne(ctx, record)
}
}

return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The Reconcile method is called from outside the actor's receive loop (as shown in the tests and described in the PR summary) but directly accesses and mutates the internal m.watches map. Since Go maps are not thread-safe, this creates a critical data race with the actor's receive loop, which concurrently reads and writes m.watches when processing incoming events. To ensure thread safety and adhere to the actor model, Reconcile should be triggered by sending a message (e.g., a reconcileMsg) to the actor's mailbox so that all state mutations are serialized on the actor's single goroutine.

Comment thread batchcanon/manager.go
Comment on lines +574 to +587
func (m *Manager) deriveAndPersist(ctx context.Context, w *batchWatch) {
next := deriveState(w)
if next == w.persisted {
return
}

if err := m.cfg.Store.UpdateBatchState(ctx, w.txid, next); err != nil {
m.logger(ctx).WarnS(ctx, "Failed to persist batch state", err,
"batch", w.txid, "state", next.String())

return
}
w.persisted = next
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The m.watches map is never pruned, which causes a memory leak as finalized or conflict-finalized batches accumulate indefinitely over the lifetime of the daemon. Furthermore, when a batch becomes StateConflictFinalized, the confirmation watch and spend watches are never unregistered. Since each registered watch in chainsource spawns a dedicated sub-actor, this leads to a permanent leak of actors and goroutines. Once a batch reaches a terminal state (StateFinalized or StateConflictFinalized), we should unregister all active watches and delete the batch from m.watches.

func (m *Manager) deriveAndPersist(ctx context.Context, w *batchWatch) {
	next := deriveState(w)
	if next == w.persisted {
		return
	}

	if err := m.cfg.Store.UpdateBatchState(ctx, w.txid, next); err != nil {
		m.logger(ctx).WarnS(ctx, "Failed to persist batch state", err,
			"batch", w.txid, "state", next.String())

		return
	}
	w.persisted = next

	if next == StateFinalized || next == StateConflictFinalized {
		if next == StateConflictFinalized {
			_ = m.cfg.ChainSource.Tell(ctx, &chainsource.UnregisterConfRequest{
				CallerID:    confCallerID(w.txid),
				Txid:        &w.txid,
				PkScript:    w.pkScript,
				TargetConfs: usabilityConfs,
			})
			m.releaseSpendWatches(ctx, w)
		}
		delete(m.watches, w.txid)
	}
}

Comment thread batchcanon/manager.go
Comment on lines +656 to +665
switch record.State {
case StateProvisional, StateConflictProvisional:
w.conf = confConfirmed

case StateReorgedOut:
w.conf = confReorgedOut

default:
w.conf = confUnseen
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

There is a race condition during restart reconciliation for batches in StateConflictProvisional. When reconcileOne is called, it initializes the inputWatch structs with conflicting = false. If the confirmation watch immediately fires a batchConfirmedMsg (which is common on startup), deriveState will compute StateProvisional because the spend watch has not yet re-delivered the spend event. This causes deriveAndPersist to overwrite the database state to StateProvisional, transiently losing the conflict state. To prevent this, consider persisting the conflict/spend status of each input in the database, or avoiding state downgrades from conflict states during the initial reconciliation phase.

@litbot-9000

Copy link
Copy Markdown
Collaborator

@ellemouton, remember to re-request review from reviewers when ready

@levmi levmi added enhancement New feature or request fsm FSM architecture and state transitions P1 Priority 1 — high reorg safety Fund-safety: stuck, lost, or mis-counted funds labels Jul 6, 2026
Squashed for the btcd v2 port. Durable batch-canonicality data model:
batchcanon package (State/Record/EffectiveExpiry/ProvisionalConsumer,
behavior-free Store), DB migration + persistence store +
BackfillFromVTXOs, and the expiry-as-terminal audit. No manager, chain
watching, or admission (those are C3+).
@ellemouton
ellemouton force-pushed the c2-batch-canonicality-data-model branch from b396fd6 to c5e00e5 Compare July 8, 2026 21:00
@ellemouton
ellemouton force-pushed the c3-batch-canonicality-manager branch from 007743b to 4f0707f Compare July 8, 2026 21:04
Squashed for the btcd v2 port. The batchcanon.Manager actor: one
reorg-aware conf watch per batch + one spend watch per consumed input
via chainsource, derives State by priority, recomputes effective
expiry on reconfirm, and reconciles non-final watches on restart. No
admission (that is C5).
@ellemouton
ellemouton force-pushed the c2-batch-canonicality-data-model branch from c5e00e5 to e7470f4 Compare July 8, 2026 22:42
@ellemouton
ellemouton force-pushed the c3-batch-canonicality-manager branch from 4f0707f to 212e4d0 Compare July 8, 2026 22:42
@ellemouton

Copy link
Copy Markdown
Member Author

Superseded by #896 as part of condensing the reorg-safety client stack (epic lightninglabs/darepo#454) from 12 PRs into 3. The commits are carried over unchanged; see #896. Branch retained as a backup.

@ellemouton ellemouton closed this Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request fsm FSM architecture and state transitions P1 Priority 1 — high reorg safety Fund-safety: stuck, lost, or mis-counted funds

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants