backfill

package
v0.41.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package backfill moves data after a schema change, in the background (zero-impact deploys, G6b). A migration changes schema only: a table-wide UPDATE inside one holds row locks in a long transaction, bloats the table and holds up the migration job before every rollout (migrate_lint_test.go refuses one from migration 0110 on, unless the file carries "-- da-ack: backfill-in-migration <reason>").

A Job is a named, idempotent step over a table by keyset: given the cursor it processes at most `limit` rows after it and says where it got to. The Runner runs jobs on one pod at a time (a Postgres advisory lock, internal/singleton), a batch at a time, each batch in its own short transaction together with the update of the job's row in `backfills`, so the data and the progress commit or roll back as one. A drain or a crash between batches loses nothing and repeats nothing; the next pod (or the next pass) resumes from the stored cursor.

Rules for a job (DA's rulings; the review checks them):

  • the new value is computed INSIDE the UPDATE statement from the row's own current columns (SET new = f(old) WHERE new IS DISTINCT FROM f(old)), never read in Go and written back: a recorder update that lands between the read and the write would be overwritten with a stale value. On the recorder's tables (drives, charges, vehicle_state, ...) a job writes only new or still-unfilled columns, never a column the recorder keeps writing;
  • idempotent (a second run over a row changes nothing), bounded (LIMIT; one batch finishes well inside the statement timeout) and key-ordered;
  • never assumed done by readers: code that reads what a backfill fills treats the old shape as valid until `done`, and a "-- contract:" removal of the old shape waits for it via contract-after;
  • registered by a DA-acked PR that carries a row-count test on a fixture.

It runs on the system pool (the one that serves sweeps, the monitor's writes and migrations), never the RLS request pool. A job is a maintenance job like the sample thinner: it touches every user's rows in key order and calls nothing outside Postgres, so it is not a group owner's write (the fence lint lists it as such).

Bound on the recorder: a batch takes its row locks in key order and holds them until it commits, so a recorder write to one of those rows waits for the rest of the batch. A batch runs under statement_timeout and lock_timeout of 5 s each (a 1000-row keyed UPDATE takes milliseconds), so the recorder's worst wait behind a backfill is about 5 s. A batch that hits either timeout (SQLSTATE 55P03, 57014) is ordinary contention: it rolls back with its progress and is retried after the pause (rivolt_backfill_retries_total); only five in a row, or any other error, mark the job failed.

Registration order: a job is registered only in a release AFTER the one whose writers (the recorder, the API) fill the new column for every new row; Fill.WriterRelease names that tag and is required. A row written behind the cursor by a writer that does not yet fill the column stays unfilled while the job reports done, and nothing walks back to it. A Fill's key (Key plus MoreKeys, a composite key walked as a row comparison) must be exactly the columns of one unique index, in its column order (checked before the first batch), so no row is skipped at a batch boundary.

Pausing: RIVOLT_BACKFILL=0 stops every job (the runner is on by default, with an empty registry); a row in `flags` named backfill.<job>.paused with value {"paused": true} stops that one job, checked before every batch (a malformed row also pauses it: the safe reading). Progress is kept either way. Default pace: 1000 rows per batch and one batch per 10 seconds (about 3 hours per million rows), per job via BatchSize and Interval.

Index

Constants

View Source
const (
	DefaultBatch       = 1000
	DefaultPause       = 10 * time.Second
	DefaultIdle        = 10 * time.Minute
	DefaultFailedRetry = 15 * time.Minute
	DefaultLockTimeout = 5 * time.Second
	DefaultStmtTimeout = 5 * time.Second // a 1000-row keyed UPDATE takes milliseconds: 5 s is a failure, not a slow batch

)

Defaults of a Runner.

View Source
const (
	StatePending = "pending"
	StateRunning = "running"
	StateDone    = "done"
	StateFailed  = "failed"
)

States of a job's row.

Variables

View Source
var RecorderTables = map[string]bool{"drives": true, "charges": true, "vehicles": true, "vehicle_state": true, "charges_capped_backup": true, "charges_deleted_backup": true}

RecorderTables are the tables the live recorder keeps writing (and the repair tools touch): a backfill there must be a Fill, so what it may write is declared and enforced in the SQL. Keep in step with db.RepairWriteTables and the partitioned vehicle_state.

Functions

This section is empty.

Types

type Fill

type Fill struct {
	// Table and Key (the primary key column) name what is walked; KeyType is its SQL type for the cursor cast (uuid, bigint, integer, text,
	// timestamptz).
	Table   string
	Key     string
	KeyType string
	// MoreKeys are the further columns of a composite key, after Key, in order (vehicle_state is keyed by vehicle_id, at). The cursor then covers the
	// whole key (row comparison, `(a, b) > (x, y)`), so rows that share a leading value are walked in full. Key plus MoreKeys must be exactly the
	// columns of one unique index, or the job is refused.
	MoreKeys []FillKey
	Columns  []FillColumn
	// WriterRelease is the final tag (vX.Y.Z) of the release whose writers (the recorder, the API) already fill these columns on every new row.
	// A job is registered only in a later release: a row written behind the cursor by an old writer is never revisited, yet the job would report
	// done. The registration PR shows the writer live in that tag.
	WriterRelease string
}

Fill is the ready-made job body for the usual backfill: fill new columns of an existing table from the row's own columns, in key order, only where they are still empty. The UPDATE is built here, so what a job may write is fixed by its declaration and enforced in the SQL itself:

  • only the declared columns are SET, and each one only where its Empty predicate holds (`CASE WHEN <empty> THEN <expr> ELSE col END`, with the same predicate in the WHERE), so a value the recorder (or a user) has written since is never overwritten, not even by a batch that read the row earlier: the row is locked (FOR UPDATE) and the predicate is re-evaluated against its current contents;
  • the new value is computed inside the statement from the row's own columns (Expr), never read in Go and written back;
  • rows are taken in key order after the cursor, a batch at a time, and a row another transaction holds is waited for (bounded by the batch's lock_timeout; a timeout fails the batch, which is retried), never skipped: skipping would leave it behind the cursor for good.

A job on a recorder table (RecorderTables) must be a Fill: Validate refuses a raw Step there.

func (Fill) Step

func (f Fill) Step() func(ctx context.Context, tx *sql.Tx, cursor string, limit int) (Result, error)

Step makes the Job.Step of a Fill.

type FillColumn

type FillColumn struct {
	Name string
	// Expr is the SQL expression for the new value, over the row's own columns (referred to as t.<col>) and constants.
	Expr string
	// Empty is the predicate that says the column is still unfilled; "" is `t.<Name> IS NULL`.
	Empty string
}

FillColumn is one column a Fill writes.

type FillKey

type FillKey struct{ Name, Type string }

FillKey is one further column of a composite key.

type Job

type Job struct {
	Name string
	// Table is the table the job writes (set from the Fill; a raw Step declares it). A job on a recorder table must be a Fill.
	Table string
	// Fill, when set, builds the job's Step (see Fill): the declared columns, written only where still empty, computed inside the UPDATE.
	Fill *Fill
	// BatchSize is the rows per batch; 0 is DefaultBatch. Interval is the wait between this job's batches; 0 is the runner's Pause.
	BatchSize int
	Interval  time.Duration
	// Step processes one batch inside tx (already bounded by lock_timeout and statement_timeout) after cursor ("" at the start).
	Step func(ctx context.Context, tx *sql.Tx, cursor string, limit int) (Result, error)
}

Job is one backfill. Name is permanent (it keys the progress row): never rename a job that has started.

func (*Job) Validate

func (j *Job) Validate() error

Validate says whether the job may be registered: a name, a Table, a Step (or a Fill, which makes one), and a Fill when the table is one of RecorderTables.

type Metrics

type Metrics struct {
	Now func() time.Time
	// contains filtered or unexported fields
}

Metrics are the backfill series. Register Collectors with the metrics registry; a nil *Metrics ignores every call.

func NewMetrics

func NewMetrics() *Metrics

NewMetrics builds the series.

func (*Metrics) Collectors

func (m *Metrics) Collectors() []prometheus.Collector

Collectors are what the metrics registry registers.

type Result

type Result struct {
	// Next is the cursor to resume from (the last key processed); unchanged when the batch found nothing.
	Next string
	// Rows is how many rows the batch changed.
	Rows int
	// Done: nothing is left after Next. A job that cannot tell returns Done when a batch changes no row.
	Done bool
}

Result is what one batch did.

type Runner

type Runner struct {
	DB   *sql.DB
	Jobs []Job
	Log  *slog.Logger
	// Pause is the wait between batches when the job sets no Interval (DefaultPause); Idle the wait between passes when every job is done or failed (DefaultIdle);
	// FailedRetry how long a failed job waits before it is tried again (DefaultFailedRetry).
	Pause       time.Duration
	Idle        time.Duration
	FailedRetry time.Duration
	Metrics     *Metrics
	// LockTimeout and StatementTimeout bound one batch (5 s each by default). They are also the longest a recorder write can wait behind a batch:
	// a batch holds its row locks until it commits, so the worst case for the recorder is StatementTimeout, about 5 s.
	LockTimeout      time.Duration
	StatementTimeout time.Duration

	// Now and Sleep are for tests.
	Now   func() time.Time
	Sleep func(ctx context.Context, d time.Duration)
	// contains filtered or unexported fields
}

Runner runs the jobs. The zero value of every field but DB and Jobs is usable.

func (*Runner) Pass

func (r *Runner) Pass(ctx context.Context) (ran, working bool, err error)

Pass runs one pass under the lock. ran is whether this pod held the lock; working whether some job still has work (it was running, or failed and will be retried).

func (*Runner) Run

func (r *Runner) Run(ctx context.Context)

Run passes until ctx ends. A pass runs every job that is not done, on the pod that holds the lock (the others skip it and look again later).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL