// Package blockchain — BadgerDB schema version tracking + migration scaffold. // // Why this exists // ─────────────── // The chain's on-disk layout is a flat KV store with string-prefixed keys // (see chain.go: prefixBalance, prefixChannel, etc.). Every breaking change // to those prefixes or value shapes would otherwise require operators to // wipe their volume and re-sync from scratch. That's painful at 10 nodes; // catastrophic at 1000. // // This file introduces a single meta-key — `schema:ver` → uint32 — that // records the layout version the data was written in. On every chain open: // // 1. We read the current version (0 if missing = fresh DB or pre-migration). // 2. We iterate forward, running each migration[k→k+1] in order, bumping // the stored version after each successful step. // 3. If CurrentSchemaVersion is already reached, zero migrations run, the // call is ~1 µs (single KV read). // // Design principles // ──────────────── // • Idempotent: a crashed migration can be re-run from scratch. Every // migration either completes its write AND updates the version in the // SAME transaction, or neither. // • Forward-only: downgrade is not supported. If an operator needs to // roll back the binary, they restore from a pre-upgrade backup. The // `update.sh` operator script checkpoints before restart for this. // • Tiny: the migration registry is a plain Go slice, not a framework. // Each migration is ~20 lines. Adding one is purely additive. // // As of this commit there are ZERO migrations (CurrentSchemaVersion = 0). // The scaffolding ships empty so the very first real migration — whenever // it lands — has a home that all deployed nodes already understand. package blockchain import ( "encoding/binary" "fmt" "log" badger "github.com/dgraph-io/badger/v4" ) const ( // schemaMetaKey is the single BadgerDB key that stores this DB's current // schema version. Not prefixed like other keys — it's a bootstrap marker // read before any prefixed query, so conflicts with userland prefixes // are impossible by construction. schemaMetaKey = "schema:ver" // CurrentSchemaVersion is the layout this binary writes. Bumped in lockstep // with every migration added below. A fresh DB is written at this version // directly (no migration chain to run). CurrentSchemaVersion uint32 = 0 ) // migration represents a single step from version v to v+1. // Apply runs inside a single badger.Update — if it returns error, nothing // is written, and the migration can be safely retried. type migration struct { From uint32 To uint32 Description string Apply func(txn *badger.Txn) error } // migrations is the ordered forward-migration registry. // // To add a migration: // // 1. Bump CurrentSchemaVersion above. // 2. Append an entry here with From = previous, To = new. // 3. In Apply, walk the relevant prefixes and rewrite keys/values. // 4. Add a unit test in schema_migrations_test.go seeding a vN-1 DB // and asserting the vN invariants after one NewChain open. // // The slice is intentionally empty right now: the scaffold ships first, // migrations land per-feature as needed. var migrations = []migration{ // no migrations yet } // readSchemaVersion returns the version stored at schemaMetaKey, or 0 if the // key is absent (interpretation: "pre-migration DB / fresh DB treat as v0"). func readSchemaVersion(db *badger.DB) (uint32, error) { var v uint32 err := db.View(func(txn *badger.Txn) error { item, err := txn.Get([]byte(schemaMetaKey)) if err == badger.ErrKeyNotFound { v = 0 return nil } if err != nil { return err } return item.Value(func(val []byte) error { if len(val) != 4 { return fmt.Errorf("schema version has unexpected length %d (want 4)", len(val)) } v = binary.BigEndian.Uint32(val) return nil }) }) return v, err } // writeSchemaVersion persists the given version under schemaMetaKey. Usually // called inside the same txn that applied the corresponding migration, so // version bump + data rewrite are atomic. runMigrations handles that. func writeSchemaVersion(txn *badger.Txn, v uint32) error { var buf [4]byte binary.BigEndian.PutUint32(buf[:], v) return txn.Set([]byte(schemaMetaKey), buf[:]) } // runMigrations applies every registered migration forward from the stored // version to CurrentSchemaVersion. Called by NewChain after badger.Open. // // Behavior: // - stored == target → no-op, returns nil // - stored < target → runs each migration[k→k+1] in sequence; if ANY // returns error, the DB is left at the last successful version and the // error is returned (no partial-migration corruption). // - stored > target → FATAL: operator is running an older binary on a // newer DB. Refuse to open rather than silently mis-interpret data. func runMigrations(db *badger.DB) error { cur, err := readSchemaVersion(db) if err != nil { return fmt.Errorf("read schema version: %w", err) } if cur == CurrentSchemaVersion { return nil } if cur > CurrentSchemaVersion { return fmt.Errorf( "chain DB is at schema v%d but this binary only understands v%d — "+ "run a newer binary OR restore from a pre-upgrade backup", cur, CurrentSchemaVersion) } log.Printf("[CHAIN] migrating schema v%d → v%d (%d steps)", cur, CurrentSchemaVersion, CurrentSchemaVersion-cur) for _, m := range migrations { if m.From < cur { continue } if m.From != cur { return fmt.Errorf("migration gap: stored=v%d, next migration expects v%d", cur, m.From) } if m.To != m.From+1 { return fmt.Errorf("migration %d→%d is not a single step", m.From, m.To) } log.Printf("[CHAIN] migration v%d→v%d: %s", m.From, m.To, m.Description) err := db.Update(func(txn *badger.Txn) error { if err := m.Apply(txn); err != nil { return err } return writeSchemaVersion(txn, m.To) }) if err != nil { return fmt.Errorf("migration v%d→v%d failed: %w", m.From, m.To, err) } cur = m.To } // Fresh DB with no migrations yet to run — stamp the current version so // we don't re-read "0 = no key" forever on later opens. if cur < CurrentSchemaVersion { err := db.Update(func(txn *badger.Txn) error { return writeSchemaVersion(txn, CurrentSchemaVersion) }) if err != nil { return fmt.Errorf("stamp schema version %d: %w", CurrentSchemaVersion, err) } } // On a brand-new DB (no chain yet) cur is still 0 but // CurrentSchemaVersion is also 0 (today), so nothing to stamp. When the // first real migration lands, this stamp becomes active. if CurrentSchemaVersion == 0 && cur == 0 { err := db.Update(func(txn *badger.Txn) error { // Only stamp if the key is absent — otherwise we already wrote it // in the loop above. if _, getErr := txn.Get([]byte(schemaMetaKey)); getErr == badger.ErrKeyNotFound { return writeSchemaVersion(txn, CurrentSchemaVersion) } return nil }) if err != nil { return fmt.Errorf("stamp initial schema version 0: %w", err) } } return nil }