benbjohnson/litestream · critical

: expected TXID but remote has

Error message

%w: expected TXID %d but remote has %d

What it means

Litestream detected a replication conflict: the remote replica has transactions (remoteTXID) newer than the TXID this VFS file expected (f.expectedTXID). Since a database replicates to exactly one destination, another writer (another node/process holding a lease) has committed LTX files ahead of this one, so local divergent history must not be silently overwritten. This wraps ErrConflict, which callers can match with errors.Is.

Solutions

  1. Stop all but one writer: ensure only a single litestream process/node replicates this database (use the S3 lease mechanism and verify lease config).
  2. Compare expected vs remote TXID in the message; if the remote is correct, reset local state (litestream reset) and re-sync from the replica.
  3. If the local database is authoritative, verify the remote history was not produced by a stale node before forcing anything; never hand-delete remote LTX files while another writer is active.
  4. After failback, let the leased primary resume replication rather than reopening an out-of-date snapshot against the live replica.

Example fix

// before: two hosts, both running replicate with same config
// after: ensure single active writer
if errors.Is(err, ErrConflict) {
    // remote is ahead: reset local cache and re-sync
    if rerr := store.Reset(ctx, dbPath); rerr != nil { log.Fatal(rerr) }
}
Defensive patterns

Strategy: validation

Validate before calling

// before sync, compare local expected TXID with remote
itr, _ := client.LTXFiles(ctx, 0, expectedTXID, false)
var remote ltx.TXID
for itr.Next() { if it := itr.Item(); it.MaxTXID > remote { remote = it.MaxTXID } }
itr.Close()
if remote > expectedTXID { return fmt.Errorf("remote ahead: %d > %d", remote, expectedTXID) }

Try / catch

err := db.Sync(ctx)
if errors.Is(err, ErrConflict) {
    // remote ahead: single-writer violated; reset local state and re-sync
    return store.Reset(ctx, dbPath)
}

Prevention

When it happens

Trigger: Sync's checkForConflict finds remoteTXID > f.expectedTXID: a second process/node replicated newer transactions while this one had cached an older expected position (e.g. after snapshot restore, failback, or two active writers without leasing).

Common situations: Running litestream on two nodes against the same database/replica (missing or expired distributed lease); failing back to a primary whose WAL was ahead; restoring an old snapshot and reopening against an advanced replica; manual litestream replicate invocations alongside the service.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of benbjohnson/litestream@4ed7a308f6 (2026-09-06). Data as JSON: /api/errors/09779e81a2df383a. Report an issue: GitHub.

Appendix: source

Thrown at vfs.go:2088

	defer itr.Close()

	var remoteTXID ltx.TXID
	for itr.Next() {
		info := itr.Item()
		if info.MaxTXID > remoteTXID {
			remoteTXID = info.MaxTXID
		}
	}
	if err := itr.Close(); err != nil {
		return fmt.Errorf("iterate remote files: %w", err)
	}

	// If remote has advanced beyond our expected position, we have a conflict
	if remoteTXID > f.expectedTXID {
		f.logger.Warn("conflict detected",
			"expected", f.expectedTXID,
			"remote", remoteTXID)
		return fmt.Errorf("%w: expected TXID %d but remote has %d",
			ErrConflict, f.expectedTXID, remoteTXID)
	}

	return nil
}

// createLTXFromDirty creates an LTX file from dirty pages.
// Returns a streaming reader for the LTX data using io.Pipe to avoid loading
// all data into memory at once.
// Must be called with f.mu held.
func (f *VFSFile) createLTXFromDirty() io.Reader {
	pr, pw := io.Pipe()

	// Sort page numbers (LTX encoder requires ordered pages)
	pgnos := make([]uint32, 0, len(f.dirty))
	for pgno := range f.dirty {
		pgnos = append(pgnos, pgno)
	}

View on GitHub (pinned to 4ed7a308f6)