timeouts

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: 2 Imported by: 0

Documentation

Overview

Package timeouts owns the ordered timeout hierarchy shared by SQLite, lifecycle ingress, slice workflows, and callers that wait for a whole workflow to finish.

A Profile holds one tier per waiting layer. A SQLite lock wait must end before either caller window can expire, and both of those must end before the caller stops waiting for the whole workflow. Ingress and StartSlice are siblings: neither is inside the other:

Tier           Field           Production   Bounds
innermost      SQLiteBusy      500ms        one SQLite lock wait, set as the
                                            DSN busy_timeout
caller window  Ingress         1s           one lifecycle receipt append,
                                            including its lock retries
caller window  StartSlice      2s           how long a slice sub-workflow
                                            waits for its start_slice signal
host window    HookInvocation  5s           how long one whole lifecycle hook
                                            invocation may take before it
                                            reports a fault to its host
outermost      WorkflowResult  30s          how long a caller waits for a
                                            whole workflow to report a result

HookInvocation sits below the smallest host budget this tree has evidence for, with headroom for process start. On Claude Code that budget is the 10s that hooks/hooks.json sets on every pasture lifecycle row ("timeout": 10). It is a value this tree configures per row and not the host's own default: a row without a timeout runs on that default, which this tree does not measure. The OpenCode plugin awaits the child process with no timeout of its own, and this tree carries no measurement of the Codex hook budget, so the tier is sized against the Claude rows; TestProductionHookInvocationSitsBelowTheSmallestHostBudget holds every lifecycle row of hooks/hooks.json to that value. A hook that outruns the host budget freezes the session, so pasture stops first and reports a fault, which fails open by default.

TestProfile (500ms / 2s / 3s / 6s / 30s) and DeadlineTestProfile (25ms / 250ms / 500ms / 1s / 2s) keep the same ordering with different budgets. New rejects any profile that inverts it.

An OPERATOR may tune three of the production tiers at run time, and the lifecycle hook reads them from its process environment: the hook-invocation deadline (PASTURE_HOOK_INVOCATION_TIMEOUT), the ingress window (PASTURE_INGRESS_TIMEOUT) and the start-slice window (PASTURE_START_SLICE_TIMEOUT). Each takes a Go duration; an unset or empty variable keeps the production value. The values are collected into an Override and applied with Profile.WithOverride, which re-runs New, so the ordering invariant above is preserved and an inverted combination is refused with the same error New returns rather than clamped.

The environment parser lives with the hook's other knobs in cmd/pasture/hook_environment.go; this package owns the values and the invariant, not the transport. The lifecycle command consumes HookInvocation (its work deadline) and Ingress (the receipt appender's deadline); StartSlice is the slice sub-workflow's signal wait, which the hook never reaches, and it is read and validated with its siblings so the invariant still covers it. The trade-off the operator owns: LOWERING the hook-invocation deadline makes a slow store fail fast — the hook abandons the work, reports a fault and answers the host fail-open, so the gate is left unevaluated while the host stops waiting at once instead of stalling for the whole window; RAISING it only helps on a fast store, because a hook that outruns the host's own budget freezes the session it was meant to protect.

Production code must read these values from an injected Profile and must not write a duration or a busy_timeout DSN literal of its own.

guard.CheckTimeoutSource enforces a narrow part of that rule: over the files listed in internal/lifecycle/guard/timeouts_test.go (eight today) it reports use of the retired DefaultIngressDeadline identifier and a string literal carrying the retired five-second busy_timeout pragma, and nothing else.

Two bootstrap retry ceilings live outside this profile on purpose, because they bound a retry loop rather than a single wait: busyRetryCeiling in internal/audit/migrate.go and dbosRaceRetryCeiling in internal/engine/dbosinit.go, both 30s. The forensic writer's private policy in internal/audit/write_retry.go instead derives one 30s operation context (or the earlier caller deadline), including pool acquisition, every transaction attempt and backoff. These retry/backoff and bounded cleanup policies are not replacements for the configured tiers.

The durable schema gate in internal/engine/schema_gate.go waits on two tiers of this profile when it finds a layout below the floor it reads: SQLiteBusy between looks at the recorded layout version, and WorkflowResult in total. No tier is defined for waiting out another process's whole bootstrap; WorkflowResult is the closest, as the outermost whole-operation wait.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Kind

type Kind uint8
const (
	Production Kind = iota + 1
	Test
	DeadlineTest
)

type Override added in v0.0.21

type Override struct {
	// HookInvocation is the whole-lifecycle-hook deadline the HOST waits on.
	// Zero keeps the base profile's value.
	HookInvocation time.Duration
	// Ingress is one lifecycle receipt append, including its lock retries.
	// Zero keeps the base profile's value.
	Ingress time.Duration
	// StartSlice is how long a slice sub-workflow waits for its start_slice
	// signal. Zero keeps the base profile's value.
	StartSlice time.Duration
}

Override collects an operator's requested substitutions for the two caller windows and the hook-invocation deadline that sits above them. A zero field means "keep the base profile's value", so an operator can tune one tier without restating the others.

THE ORDERING INVARIANT IS NOT OPTIONAL AND IS NOT CLAMPED. These tiers are not three independent numbers: a hook invocation CONTAINS the receipt append it performs, so its deadline must sit strictly above both caller windows, and the workflow-result wait must sit strictly above all of them. An override is therefore handed to New, which refuses an inverted combination with the same error it has always returned. Silently clamping a rejected value would leave the operator believing a budget the code does not honour.

func (Override) IsZero added in v0.0.21

func (o Override) IsZero() bool

IsZero reports whether the override asks for no change at all. It holds for the zero Override, which is what an unset environment produces.

type Profile

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

Profile is immutable and constructor-validated. The five tiers are ordered from innermost to outermost: a SQLite lock wait must complete before either the ingress or the start_slice window can expire; both of those must complete before one hook invocation runs out of time; and that must complete before the caller stops waiting for the whole workflow.

func DeadlineTestProfile

func DeadlineTestProfile() Profile

DeadlineTestProfile is deliberately tight and is used only by tests proving deadline breach and crash-safe zero-receipt behavior.

func KnownProfiles

func KnownProfiles() []Profile

func New

func New(kind Kind, sqliteBusy, ingress, startSlice, hookInvocation, workflowResult time.Duration) (Profile, error)

func ProductionProfile

func ProductionProfile() Profile

ProductionProfile allocates half of ingress to each SQLite lock wait. This preserves useful WAL contention absorption while leaving strict headroom below the one- and two-second caller windows.

func TestProfile

func TestProfile() Profile

TestProfile favors deterministic integration runs: its two-second ingress window exceeds the measured 48-writer serialized queue, while a 500 ms inner retry remains strictly below both callers.

func (Profile) HookInvocation added in v0.0.9

func (p Profile) HookInvocation() time.Duration

HookInvocation bounds ONE whole lifecycle hook invocation, from process start to the outcome the host reads. It exists because the host, not pasture, pays for a hook that does not return: the session freezes while it waits. The value sits below the smallest host budget with headroom for process start, so pasture stops first and reports a fault, which fails open by default.

func (Profile) Ingress

func (p Profile) Ingress() time.Duration

func (Profile) IsZero

func (p Profile) IsZero() bool

func (Profile) Kind

func (p Profile) Kind() Kind

func (Profile) SQLiteBusy

func (p Profile) SQLiteBusy() time.Duration

func (Profile) StartSlice

func (p Profile) StartSlice() time.Duration

func (Profile) Validate

func (p Profile) Validate() error

func (Profile) WithOverride added in v0.0.21

func (base Profile) WithOverride(o Override) (Profile, error)

WithOverride returns base with every non-zero field of o substituted and re-runs New over the result, so the whole hierarchy is re-validated and an inverted combination is refused with New's error rather than clamped.

With the zero Override it returns base unchanged, which is why the default production behaviour is untouched when no knob is set: the caller passes ProductionProfile and receives the same ordered profile back.

func (Profile) WorkflowResult added in v0.0.8

func (p Profile) WorkflowResult() time.Duration

WorkflowResult is the outermost tier: how long a caller waits for a whole durable workflow to report its result. It is above every inner window on purpose. A caller that gives up sooner than the inner windows would report a timeout for work that was still inside its own budget, which hides the real state of the workflow and makes the failure hard to read.

Jump to

Keyboard shortcuts

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