writelane

package
v0.0.21 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package writelane owns the daemon's single serialized lifecycle write lane: the ONE budget splitter (WriteLaneBudget + ExceedsWriteBatch) and the ONE goroutine that commits admitted records.

The lane exists because the contended resource is SQLite's single writer slot, not throughput: every hook process opened pasture.db and asked for a write lock at least twice per event, so a convoy of short-lived writers starved one another and each hook reported a fault it could not interpret. Putting the lane in front of the store serializes those writes by construction and bounds how long any one turn holds the writer, so the engine's and the CLI's own writers are never starved past their busy window.

ONE SPLITTER. Every place that groups records into a batch asks ExceedsWriteBatch; no site re-derives a count or byte bound. The sibling repository's ingest lane (internal/ingest/pipeline.go ExceedsWriteBudget) is the pattern this package adapts, and the reason for a single splitter is the same one stated there: two sites spelling out the same terms drift.

Index

Constants

This section is empty.

Variables

View Source
var ErrLaneClosed = errors.New("the lifecycle write lane is closed")

ErrLaneClosed is returned by Submit once the lane has been closed: no record was admitted, so the caller may honestly report that nothing was committed for it.

View Source
var ErrNotAdmitted = errors.New("the lifecycle write lane did not admit the record")

ErrNotAdmitted marks a Submit that staged NOTHING: the lane was closed, the caller's own context ended while the queue was at its bound, or the request itself was malformed. It is the evidence for a provably not-recorded claim, exactly as ErrLifecycleBeforeDurableWrite is on the direct path, and the served handler converts it into that sentinel. An error from the COMMIT is never wrapped in it.

Functions

func ContextWithTurnScope

func ContextWithTurnScope(ctx context.Context, scope *TurnScope) context.Context

ContextWithTurnScope returns a context carrying scope for one commit turn.

func ExceedsWriteBatch

func ExceedsWriteBatch(b WriteLaneBudget, pendingRecords int, pendingBytes, nextBytes int64) bool

ExceedsWriteBatch reports whether the pending batch must be flushed BEFORE the next record joins it. It is the ONE splitter: the single home of the count and byte terms.

A record larger than the whole batch budget is still admitted, alone: the batch it would join is flushed first and the empty-batch term then admits it. Refusing it instead would lose an event that happened.

func OutcomeIsDurable

func OutcomeIsDurable(outcome provenance.BatchRecordOutcome) bool

OutcomeIsDurable reports whether a batch outcome means the record is durably recorded after the call: BatchRecordCommitted (this batch's transaction wrote it) or BatchRecordShortCircuited (an earlier transaction did). Every other outcome means nothing this batch folded is durable for the record.

func Report

func Report(outcome provenance.BatchRecordResult) error

Report maps one record's batch outcome to the error the lane reports for that record. It is the ONE home of the mapping, so the lane and the served path cannot drift about what an outcome means.

  • nil means the record is durably recorded: committed by this batch, or short-circuited because an EARLIER transaction already committed it. A caller that gets nil may claim durability; a short-circuit is NOT a failure.
  • Every other outcome (conflict, failed, not-recorded) returns a typed, actionable error. A record the batch rolled back is never reported as a commit, and the whole-batch failure is never reported as a partial commit.

Types

type BatchApply

type BatchApply func(ctx context.Context, inputs []provenance.OperationInput) (provenance.BatchResult, error)

BatchApply commits a flush's prepared operations in ONE transaction and returns one outcome per input, aligned by index. It is the lane's whole reason for phase 2: the per-record lane ran one transaction per record, and the pinned Provenance BatchJournal folds the batch through the same per-operation reducer step in a single BEGIN IMMEDIATE.

type Lane

type Lane struct {
	// contains filtered or unexported fields
}

Lane is the ONE serialized writer for lifecycle records: a bounded staging queue of admitted records, one goroutine that commits them in bounded batches, and per-record completion so a caller learns whether ITS record committed.

THE CALLER LEARNS NOTHING BEFORE THE COMMIT. Submit returns only after the record's Settle has run and reported its result, so there is no path on which a hook is told "recorded" for a record that has not committed. That is the property the whole daemon-fronted lane exists to preserve, and it is a property of Submit's shape rather than of any discipline at the call site.

func New

func New(budget WriteLaneBudget, apply BatchApply) (*Lane, error)

New constructs the lane and starts its commit goroutine. The budget is validated here, so a lane that exists is a lane whose bounds hold; the batch applier is required, because a lane with no way to commit a flush would admit records it could never make durable.

func (*Lane) Budget

func (l *Lane) Budget() WriteLaneBudget

Budget returns the validated budget this lane runs on.

func (*Lane) Close

func (l *Lane) Close(ctx context.Context) error

Close stops admission and drains what is already staged inside the ctx budget, then stops the commit goroutine. A drain that does not finish in time returns the ctx error; the records still staged are lost, which is why the daemon reports every hook that was still waiting as record-unknown rather than recorded.

func (*Lane) Stats

func (l *Lane) Stats() Stats

Stats reports a snapshot of the lane's counters.

func (*Lane) Submit

func (l *Lane) Submit(ctx context.Context, bytes int64, record Record) error

Submit admits one record and blocks until it has committed or failed.

ADMISSION BLOCKS RATHER THAN REFUSING. When the queue is at its record or byte bound the caller waits for room inside its own context; the connection handler is what waits, so no further request is read from the socket and the pressure is passed back to the hook, which abandons at its own hook-invocation deadline exactly as it does against a slow direct write. No new refusal path is introduced.

A record larger than the whole batch byte budget is admitted when the queue is empty and committed ALONE; refusing it would lose an event that happened.

type Record

type Record interface {
	Prepare(ctx context.Context) (provenance.OperationInput, error)
	Settle(ctx context.Context, outcome provenance.BatchRecordResult) error
}

Record is one admitted record's durable work, expressed in two phases so the lane can commit a whole flush in ONE provenance transaction:

  • Prepare runs everything that must happen BEFORE the shared write transaction — the payload blob write, identity resolution, and canonicalization — and returns the record's canonical journal operation. A Prepare error is this record's own: it contributes no operation to the batch and the lane reports that error to its caller. Anything durable it left behind is a reclaimable payload blob, never a journal row.
  • Settle reports this record's own outcome to its caller and runs any work that must follow the shared commit. It is called exactly once for every record whose Prepare succeeded, with that record's own BatchRecordResult, whatever the batch did.

The context both phases receive is the LANE's, not the submitter's: a record a hook abandoned while it waited in the queue is still committed (evidence over silence), so neither phase may be cancelled by the hook's own deadline.

type Stats

type Stats struct {
	Records         int64
	Batches         int64
	MaxBatchRecords int64
	MaxHold         time.Duration
	QueueDepth      int64
	HoldExceeded    int64
}

Stats is a read-only snapshot of what the lane has done. It exists so an operator, a test and the startup log line can read the same numbers without reaching into the lane's state.

type TurnScope

type TurnScope struct {
	// contains filtered or unexported fields
}

TurnScope carries state shared by every record prepared in one commit turn. A turn is one trip through the lane's prepare-every-record loop: the lane creates a scope, prepares every record of the flush under it, applies the batch, and drops it. Records use it to share preparation reads that are constant within a turn — content-addressed owner facts resolved from monotonic journal truth, where every record of the turn would otherwise repeat the identical indexed lookup and resolution against the same rows.

The scope never outlives the batch apply, so no invalidation or cross-turn staleness exists: the next turn re-resolves everything. A failed share is never stored, so one record's pre-transaction fault cannot poison the turn. Records prepared outside a lane (the direct path) see no scope and prepare standalone, exactly as before.

func NewTurnScope

func NewTurnScope() *TurnScope

NewTurnScope returns an empty per-turn share.

func TurnScopeFrom

func TurnScopeFrom(ctx context.Context) *TurnScope

TurnScopeFrom returns the commit-turn share carried by ctx, or nil when the record is prepared outside a lane turn.

func (*TurnScope) Get

func (s *TurnScope) Get(key any) (any, bool)

Get returns the value stored under key, if any.

func (*TurnScope) Set

func (s *TurnScope) Set(key any, value any)

Set stores value under key, overwriting any prior entry.

type WriteLaneBudget

type WriteLaneBudget struct {
	// QueueRecords bounds the number of admitted-but-uncommitted records. When
	// the queue is at this bound a new request BLOCKS in the lane until a
	// commit frees room; the connection handler is what blocks, so a further
	// request is not read from the socket (natural backpressure). It is a
	// bound on staged memory and an admission brake, never a refusal path: an
	// invocation waiting here is abandoned by its own hook-invocation deadline
	// exactly as a slow direct write is today.
	QueueRecords int
	// QueueBytes bounds the staged payload bytes across the whole queue, the
	// byte half of the same brake.
	QueueBytes int64
	// BatchRecords bounds ONE lane turn's record count. The turn is what holds
	// the store writer, so this count times the per-record commit cost must
	// stay inside HoldTarget.
	BatchRecords int
	// BatchBytes bounds ONE lane turn's staged payload bytes on the same terms.
	BatchBytes int64
	// FlushInterval is the IDLE FALLBACK only: the longest a non-empty partial
	// batch waits for a companion record before it is committed anyway. It must
	// stay well below the hook-invocation tier, because every record in the
	// batch is still waiting for its acknowledgement.
	FlushInterval time.Duration
	// HoldTarget bounds how long ONE lane turn may hold the SQLite writer, so
	// the engine's own writes and every CLI writer keep their measured wait.
	// It is derived below the other writers' busy tier.
	HoldTarget time.Duration
}

WriteLaneBudget is every bound the lane enforces, in one value. It is constructor-validated: the daemon resolves it once from the timeout profile (BudgetFromProfile) and an operator may override named knobs, but the value the lane runs on always passes Validate, so an inverted combination is refused with a named error rather than clamped.

func BudgetFromProfile

func BudgetFromProfile(profile timeouts.Profile) (WriteLaneBudget, error)

BudgetFromProfile derives the lane budget from one validated timeout profile: the profile is the single home of the tiers, so the lane joins it rather than carrying a second, independent set of durations.

The derivation, and why each term is what it is:

  • HoldTarget is four fifths of the profile's SQLite busy tier, capped by maxDerivedHoldTarget. The busy tier is the window every OTHER writer waits for the lock, so a lane turn that stays inside four fifths of it leaves scheduling headroom instead of becoming the holder that starves them.
  • BatchRecords is HoldTarget divided by assumedPerRecordCommitCost, floored at one, so the turn's record count is sized against the hold bound rather than picked.
  • BatchBytes is 8 MiB: eight times the 1 MiB ingress payload cap, so a queue of concurrent maximum-size payloads cannot itself become the memory bound (the design's stated start).
  • QueueRecords/QueueBytes are the staging bounds: 256 records and four times BatchBytes, so the queue is deeper than one turn but still bounded.
  • FlushInterval is 25 ms, the design's stated start, and never more than a quarter of HoldTarget. It is the ONLY wait a lone record can add to the hook path, so it is bounded hard: it stays well below the hook-invocation tier, and it is below a hold bound that is itself below the busy tier.

func (WriteLaneBudget) Validate

func (b WriteLaneBudget) Validate() error

Validate refuses an unusable budget with an error naming the knob and the relationship that failed, so an operator who set one via the environment is told which one and what it collides with. It never clamps.

Jump to

Keyboard shortcuts

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