Documentation
¶
Overview ¶
Package pgnotch keeps append-only, fenced logs in stock PostgreSQL: no extension, no background worker, no server-side code of its own.
A log is a gap-free sequence of entries under an identifier the caller chooses, created by Store.CreateLogs and never as a side effect. One writer owns it at a time, at an epoch Store.Fence takes, so a writer that has lost its log finds out at its next append. The owner assigns seqnos itself, which lets an append be a single statement.
What a caller may rely on ¶
- Total order per log, with no tie to break and no clock in the design.
- Fencing. A completed Store.Fence cuts off every append of a lower epoch, atomically.
- Cumulative ack. An Store.Append that returns nil means every entry up to and including the batch's last seqno is durable.
- Gap-freedom. An append never skips a seqno: Store.Trim moves a log's lower end, an append its upper end, and nothing puts a hole between.
- Readback. Store.ReadFrom returns every entry a completed append acked and no trim has removed, in seqno order, across a change of owner.
- Handover. Store.NextSeqno names the seqno a new owner's first append must start at, for a log whose entries a trim has all taken included.
Payloads are opaque bytes; nothing here interprets or frames them.
How it works ¶
Per log there is one row in the registry table holding the owning epoch, the last seqno appended and the trim watermark. An append is one statement: an UPDATE whose WHERE clause is at once the fencing check, the gap check and the already-written check, decided under that row's lock, with the entry rows in the same CTE. Nothing is derived from the entry rows, so a trim can take all of them while ErrAlreadyWritten still answers for their seqnos.
The entry tables carry no index: the registry row is the authority on which seqnos are spent, and `_bt_check_unique` reads under SnapshotDirty, so an index could not see the rows a trim removed. Nor a fillfactor — measured, it only moved the full-page image to the free-space map page, at eight times the space for a 900-byte entry. The payload column is `bytea STORAGE PLAIN`, which keeps PostgreSQL from ever creating a TOAST relation for these tables, at the price of a hard "row is too big" past a page, so entries are chunked at MaxEntryChunk and a caller never sees a chunk.
Space comes back by TRUNCATE, never by DELETE: each log has two entry tables used as a ring, and the one a trim has emptied is truncated, discarding its dirty buffers unwritten and taking its whole vacuum debt with it.
Operating it ¶
An append is one round trip only while the driver has the statement prepared. It names its own log's tables, so a connection holds one per log it has touched; a smaller driver cache misses every append and the round trip becomes three. pgx caches 512, set in the DSN by `statement_cache_capacity`.
Migrate applies the static half of the schema and Open refuses one nobody has migrated. The migrations are ordinary goose SQL files, embedded here and shipped in `migrations/`; Provider hands back the goose provider itself, with the warning it carries about the down. The per-log entry tables are not versioned and cannot be — Store.CreateLogs makes them in the transaction that registers each log, and a migration over an unbounded set of tables is not something this package has.
Every name this package writes is unqualified, so all of them land in the schema the connection's search_path names: two [Store]s over one schema are two writers of the same logs, two over different schemas share nothing.
Requires PostgreSQL 16 or newer: `bytea STORAGE PLAIN` in CREATE TABLE, rather than a following ALTER, arrived there.
Index ¶
- Constants
- Variables
- func Drop(ctx context.Context, pool *pgxpool.Pool) error
- func Migrate(ctx context.Context, pool *pgxpool.Pool) error
- func Provider(db *sql.DB, opts ...goose.ProviderOption) (*goose.Provider, error)
- type Entry
- type Epoch
- type LogID
- type Seqno
- type Store
- func (s *Store) Append(ctx context.Context, id LogID, epoch Epoch, first Seqno, payloads [][]byte) error
- func (s *Store) CreateLogs(ctx context.Context, ids ...LogID) (err error)
- func (s *Store) Fence(ctx context.Context, id LogID, epoch Epoch) error
- func (s *Store) NextSeqno(ctx context.Context, id LogID) (Seqno, error)
- func (s *Store) ReadFrom(ctx context.Context, id LogID, from Seqno, limit int) ([]Entry, error)
- func (s *Store) Trim(ctx context.Context, id LogID, upTo Seqno) error
Constants ¶
const MaxEntryChunk = 8000
MaxEntryChunk is the payload one row carries: a heap tuple must fit a page (PostgreSQL's MaxHeapTupleSize is 8160), the row's other columns and headers take about fifty bytes, and the rest is slack. A larger entry becomes several rows, which a caller never sees — entries go in whole and come back whole.
const MaxLogIDBytes = 255
MaxLogIDBytes bounds a LogID, so an id too long is refused where it is passed rather than at the first append that overflows an index entry.
Variables ¶
var ( // ErrFenced means the log is not the caller's to write: some other epoch // has fenced it, or the caller never fenced it at its own epoch. The caller // must stop writing. ErrFenced = errors.New("pgnotch: log is not fenced at this epoch") // ErrAlreadyWritten means a seqno the append asked for is taken. After an // append that failed ambiguously it says the write landed, and so is a // retry's success signal — provided the retry is the same batch: same first // seqno, same number of payloads, nothing added to it. A batch overlapping // the log only partly is refused whole. [ErrFenced] outranks it, so a // writer whose epoch grew across the ambiguity must replay under the epoch // it holds now, or read the log, to learn whether the first attempt landed. ErrAlreadyWritten = errors.New("pgnotch: seqno already written") // ErrGap means the append would leave a hole: the entry below the batch is // missing. Expected of a pipelined append that arrived out of order; retry // once the predecessor lands. ErrGap = errors.New("pgnotch: predecessor seqno is missing") // ErrNoSuchLog means the log has not been created. [Store.Fence] returns it // rather than creating one: see [Store.CreateLogs]. ErrNoSuchLog = errors.New("pgnotch: no such log") // ErrZeroEpoch is what a caller that forgot to set an epoch gets, rather // than an [ErrFenced] that reads like a lost log: epoch 0 means "nobody // owns this". ErrZeroEpoch = errors.New("pgnotch: epoch 0 is not a valid epoch") // ErrNotMigrated means the schema has no tables of this package in it yet. // [Open] returns it rather than creating tables, so a process which is not // the one that deploys cannot become the one that migrates; call [Migrate] // and open again. ErrNotMigrated = errors.New("pgnotch: schema is not migrated") )
Errors a caller is expected to handle; anything else it can only report. Match with errors.Is; the errors returned wrap these with context.
Functions ¶
func Drop ¶
Drop removes every table this package owns: its logs' entry tables, the registry and goose's version table. The schema itself is left alone; an operator giving that back too wants DROP SCHEMA.
A Store does not survive it: it caches which tables each log's entries are in, and since ordinal is GENERATED ALWAYS AS IDENTITY the identity goes with the registry table, so a re-migrated schema hands out ordinal 1 again and those cached names are either gone or a later log's, appended to without error. Drop what nothing is using, and open again afterwards.
func Migrate ¶
Migrate applies the schema to whatever schema the pool's search_path names. It is safe to run concurrently with itself and with a running Store: goose takes the version table's lock, and no migration touches a log's entry tables. It does not close pool.
func Provider ¶
Provider is the goose provider for this package's schema, for an operator who wants what goose offers beyond Migrate: status, a targeted up-to, a down. The down takes every log's entries with it, since the registry migration is the only thing that can enumerate the entry tables.
db may be anything that speaks to the right database; stdlib.OpenDBFromPool turns a pool into one and closing the result leaves the pool open. Options given here are applied after this package's own, so goose.WithLogger gets back the logging the goose.NopLogger installed here takes away.
Types ¶
type Entry ¶
type Entry struct {
// Seqno is the entry's position in its log.
Seqno Seqno
// Epoch is the epoch its writer held when it appended the entry. Epochs
// are non-decreasing along a log.
Epoch Epoch
// Payload is the bytes the caller appended, and is never nil for an entry
// a read returns: an empty payload comes back empty, not missing. The
// bytes alias neither this package's state nor another entry of the same
// read, so the caller may keep them and decode into them in place.
Payload []byte
}
Entry is one record of a log.
type Epoch ¶
type Epoch uint64
Epoch is the ownership token every append carries. It may grow without an ownership change: a writer renewing its claim fences again at a higher epoch and keeps its log.
Zero is not a valid epoch; see ErrZeroEpoch. Whoever hands epochs out owes this package a strictly greater epoch per acquire, since fencing cannot separate two writers holding the same one.
type LogID ¶
type LogID string
LogID names one log: an opaque key this package never interprets. Logs under different ids share nothing.
It is stored as `text` and never appears in an identifier — a log's tables are named from an ordinal assigned at creation — so the constraints are only that it be at most MaxLogIDBytes, not empty, valid UTF-8 and free of NUL.
type Seqno ¶
type Seqno uint64
Seqno is the position of an entry in one log: an LSN the owner assigns itself. Total order within a log, no gaps.
const FirstSeqno Seqno = 1
FirstSeqno is the seqno of a log's first entry. Seqnos below it are not entries; they are reserved for this package's own bookkeeping.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the logs in one PostgreSQL schema, whichever the pool's search_path names. It is safe for concurrent use, but two concurrent appends to the same log race for seqnos and lose. It does not own the pool and never closes it.
func Open ¶
Open returns a Store over the schema the pool's search_path names, which must already have been migrated: see Migrate, and ErrNotMigrated for why this is not done here. It does not take ownership of pool.
func (*Store) Append ¶
func (s *Store) Append(ctx context.Context, id LogID, epoch Epoch, first Seqno, payloads [][]byte) error
Append writes payloads as entries at consecutive seqnos starting at first, under epoch, as one atomic unit. first must be at least FirstSeqno and the batch must hold at least one payload; an individual payload may be empty. The payloads stay the caller's: no slice is retained or read after Append returns. Returning nil means the batch is durable through its last seqno.
Errors:
- ErrFenced when the log belongs to another epoch, or to nobody,
- ErrAlreadyWritten when any seqno in the batch is taken,
- ErrGap when the entry below first is missing.
None of the three writes anything; where more than one applies ErrFenced wins, because ErrAlreadyWritten is an ack a lost writer would trust.
It is one statement: an explicit transaction would hold the registry row across BEGIN, the UPDATE, the rows and COMMIT. COPY, the only path to heap_multi_insert, carries neither the predicate nor the refusal, so the rows go in by INSERT and pay a WAL record apiece rather than one per page — 41 bytes a row more at 900-byte entries (~4%), nothing at page-sized ones, where a row is its own page.
func (*Store) CreateLogs ¶
CreateLogs brings logs into existence, and is the only thing here that ever creates a table. It is idempotent: ids that already exist are left exactly as they are, ownership and entries included. A created log has no owner, so an Store.Append to it is refused with ErrFenced until someone fences it, and either every id in the batch exists when this returns nil, or none of the ones it had to create do.
Creation is separate from fencing because a LogID is an arbitrary string: a fence that conjured a log would leave tables behind on one bad id.
func (*Store) Fence ¶
Fence claims a log for epoch, atomically cutting off every append of a lower epoch, so a writer that has lost the log cannot slip an append past a completed Fence. It is idempotent per epoch, so a restart without a change of ownership can replay the same acquire path; fencing at a higher epoch is how the same owner renews, and at a lower one fails with ErrFenced.
Ownership is all a fence changes: the entries stay and the new owner continues the log at the next seqno. A failed fence changes nothing, and fencing a log that does not exist is ErrNoSuchLog and creates nothing.
func (*Store) NextSeqno ¶
NextSeqno is the seqno the log's next append must start at: one past its last entry, and FirstSeqno for a log nothing has appended to. A log that does not exist is ErrNoSuchLog. It is the registry row's and is not derived from the entries, so it is right for a log a trim has emptied.
A new owner asks once and keeps the mark itself from there: the log's end after an Store.Append is that batch's last seqno, and asking again per append would put a second round trip on the one call that has only one.
It answers for the log only while the caller owns it — an append by a higher epoch moves it — which is not a race to lose: appending at a stale value is refused rather than misplaced.
func (*Store) ReadFrom ¶
ReadFrom returns up to limit entries of the log with seqno at or above from, in seqno order. limit must be positive, and a from below FirstSeqno reads from FirstSeqno. Fewer than limit entries means the log ends there, so a caller reading a whole log loops until a short read.
A log nothing has fenced reads as empty, and a read after a successful Store.Fence sees every entry the log held when the fence took it.
func (*Store) Trim ¶
Trim removes the log's entries at or below upTo. Trimming entries that are not there is not an error: Trim states where the log should start, and repeating it is harmless. The log stays appendable at the next seqno, ownership stays put, and the seqnos removed stay spent — an append at one is ErrAlreadyWritten.
The watermark moves synchronously and the space comes back when it can: a read is gated on the watermark, so rows may outlive it, never their visibility.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
pgnotch-load
command
Command pgnotch-load puts an unbounded append load on pgnotch logs.
|
Command pgnotch-load puts an unbounded append load on pgnotch logs. |