chaos

package
v0.0.6 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package chaos provides a fault-injection framework that lets cloop's resilience claims (retry budgets, circuit breakers, busy-timeout pragmas, graceful shutdown) be exercised against simulated failures rather than just unit-tested.

Design overview

The framework has four collaborating pieces:

  1. A Controller that holds the currently-active faults in memory plus a file-watched mirror at .cloop/chaos/active.json. The mirror lets a `cloop chaos inject` invocation in one process influence a long-lived `cloop ui` daemon in another, without IPC plumbing.
  2. Middleware that wraps the provider HTTP client (see Transport) and consults the controller on every request to decide whether to inject a timeout, 429, 500, or network flap.
  3. A SQLite busy holder (see SQLiteBusy) that opens a separate connection to the project's state database and parks a write transaction for the configured duration, forcing every other writer to time out under WAL/busy_timeout — the realistic SQLITE_BUSY scenario.
  4. A persistent journal at the chaos_runs SQLite table that records every injected fault and its observed outcome, so `cloop chaos report` can summarise how the system handled each fault.

Fault catalogue

The supported fault types are deliberately a small, fixed set that map to the failure modes the rest of cloop is designed to survive:

provider-timeout         — every outbound HTTP call to a provider hangs
                           past the controller's per-fault timeout, then
                           is cancelled with context.DeadlineExceeded.
provider-429             — provider responses are replaced with HTTP 429
                           (rate limited). Exercises retry/backoff.
provider-500             — provider responses are replaced with HTTP 500.
                           Exercises retry-on-5xx and circuit breaker.
sqlite-busy              — a sibling write transaction is held open on
                           .cloop/state.db, forcing concurrent writers
                           to wait out busy_timeout.
network-flap             — a configurable percentage of HTTP requests
                           fail with connection-refused-like errors.
disk-full-simulation     — temporarily fills .cloop/chaos/ballast with a
                           large file so writes intended for that
                           directory fail with ENOSPC-like errors.
slow-disk                — wraps atomicfile writes with an artificial
                           delay (caller-side; see SlowDisk).

Faults are intentionally additive: injecting two compatible faults at once (e.g. provider-429 + sqlite-busy) lets you stress multiple components simultaneously. Each fault carries an Until time after which it is silently dropped, so a forgotten injection cannot poison the system permanently.

Index

Constants

This section is empty.

Variables

AllFaultTypes lists every fault the framework knows how to inject. Used by CLI validation and by the `cloop chaos suite` runner.

Functions

func AdvanceForTesting

func AdvanceForTesting(b []byte) []byte

AdvanceForTesting is a tiny helper used by chaos_test.go to deterministically drain pending request bodies without leaking goroutines. Exposed because Go vet flags unused imports otherwise.

func FormatSummary

func FormatSummary(rows []SummaryRow) string

FormatSummary renders summary rows as a fixed-width table suitable for printing to a terminal. Includes a totals footer.

func MaybeSleepSlowDisk

func MaybeSleepSlowDisk(c *Controller)

MaybeSleepSlowDisk applies the slow-disk delay if active. Centralising the helper means atomicfile and other writers don't have to know how the delay is configured. Safe to call frequently — returns immediately when no fault is active.

func SlowDiskDelay

func SlowDiskDelay(c *Controller) time.Duration

SlowDiskDelay returns the artificial delay configured for an active slow-disk fault, or zero when no slow-disk fault is active. The delay is stored in the fault's Note field as a Go duration string (e.g. "150ms"). Callers wrapping disk writes can use this to introduce latency.

func WrapTransport

func WrapTransport(base http.RoundTripper, c *Controller) http.RoundTripper

WrapTransport returns base wrapped in a chaos Transport that consults the given controller. Pass nil for c to consult chaos.Global() instead.

Types

type BusyHolder

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

BusyHolder injects sqlite-busy contention by opening a sibling connection to the project's state database and parking a write transaction inside it. While the transaction is open, every other writer that exceeds the project's busy_timeout returns SQLITE_BUSY — the realistic SQLITE_BUSY scenario the rest of cloop's code is designed to survive.

The holder uses an *exclusive* transaction (BEGIN IMMEDIATE) so reads from other connections still proceed under WAL — only writers contend, which matches the production failure mode we want to reproduce.

Concurrency: a holder is single-shot. Start opens the transaction, Stop rolls it back. Stop is safe to call multiple times and returns the wall- clock duration the transaction was actually held.

func NewBusyHolder

func NewBusyHolder(dbPath string) *BusyHolder

NewBusyHolder creates a holder targeting the given database file.

func (*BusyHolder) Start

func (h *BusyHolder) Start(ctx context.Context) error

Start opens the auxiliary connection and begins the immediate transaction. Must be paired with Stop. Returns an error if the database file is missing or the BEGIN IMMEDIATE itself cannot acquire the write lock within busyTimeout.

The transaction is held by writing to a chaos-private throwaway table so we never mutate any production row. The table is created lazily and keeps a single dummy row whose value we increment on each Start invocation.

func (*BusyHolder) Stop

func (h *BusyHolder) Stop() time.Duration

Stop rolls back the transaction (releasing the write lock) and closes the auxiliary connection. Returns the wall-clock time the lock was held. Safe to call multiple times; subsequent calls return the same duration.

type Controller

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

Controller is the in-process owner of currently-active faults. A single instance is shared across the orchestrator, provider HTTP clients, and any other subsystem that wants to participate in chaos.

Concurrency: the active set is guarded by a sync.RWMutex, with reads dominating in the hot HTTP path. The refresher goroutine is the sole writer outside the public Inject/Clear methods.

Lifecycle: NewController returns an idle controller with an empty active set; call Start(ctx) to begin file-watching. The controller is safe to instantiate even when no chaos is intended — every ShouldInject fast-path returns false in zero time when the active set is empty (atomic counter).

func Global

func Global() *Controller

Global returns the currently-installed controller, or nil when none has been configured. The HTTP transport treats a nil controller as "chaos disabled" — the safe production default.

func NewController

func NewController(workDir string) *Controller

NewController creates a controller scoped to the given .cloop project root. workDir must be a directory that contains, or is allowed to contain, a .cloop subdirectory; for non-project use cases (tests, suites) any writable directory works.

func SetGlobal

func SetGlobal(c *Controller) *Controller

SetGlobal installs c as the package-wide controller. Returns the previous controller so tests can restore it.

func (*Controller) Active

func (c *Controller) Active() []Fault

Active returns a snapshot copy of the currently-active faults. Safe to retain — the slice does not alias the controller's internal storage.

func (*Controller) Clear

func (c *Controller) Clear() error

Clear removes every persisted fault. Intended for cleanup at the end of a chaos run or when the operator wants to abort an in-flight injection.

func (*Controller) FaultsOfType

func (c *Controller) FaultsOfType(t FaultType) []Fault

FaultsOfType returns active faults matching t. Useful for callers that need the configured Probability or Note (e.g. SlowDisk reads its delay from the note field).

func (*Controller) Inject

func (c *Controller) Inject(f Fault) error

Inject persists the fault to the on-disk mirror so all listening processes pick it up on their next refresh. The fault's StartedAt is set to now if zero. ID is left unset — callers that want the fault recorded in chaos_runs must additionally call (*Store).Insert and update the persisted entry.

func (*Controller) LastError

func (c *Controller) LastError() error

LastError returns the last error encountered while refreshing the active set, or nil if the most recent refresh succeeded.

func (*Controller) Refresh

func (c *Controller) Refresh() error

Refresh reloads the active set from disk. Returns the last load error so callers can surface mirror-corruption to the user.

func (*Controller) ShouldInject

func (c *Controller) ShouldInject(t FaultType) bool

ShouldInject reports whether a fault of the given type is currently active and the probability roll succeeded. The hot path on the no-fault case is a single atomic load and a comparison — designed so chaos middleware adds no measurable overhead in production.

func (*Controller) Start

func (c *Controller) Start(ctx context.Context)

Start launches the file-watcher goroutine that refreshes the active set every defaultRefresh. Idempotent: subsequent calls are no-ops.

func (*Controller) Stop

func (c *Controller) Stop()

Stop terminates the watcher goroutine. Safe to call multiple times.

type DiskFullSimulator

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

DiskFullSimulator creates a large ballast file inside .cloop/chaos/ to occupy disk space, simulating an ENOSPC environment for code paths that write into the project tree. The simulator targets a fixed byte size; if the filesystem cannot accommodate it, we surface that directly to the caller (which, for chaos purposes, is the desired outcome).

The simulator never deletes files outside .cloop/chaos/ballast, so it is safe to run alongside other chaos faults. Stop() removes the ballast file regardless of how Start exited.

func NewDiskFullSimulator

func NewDiskFullSimulator(workDir string, sizeBytes int64) *DiskFullSimulator

NewDiskFullSimulator targets the given .cloop project root. The default size (256 MiB) is comfortably above the typical free-space buffer most developer machines keep, while still far below the level that would cause genuine cascading failures.

func (*DiskFullSimulator) Start

func (d *DiskFullSimulator) Start() error

Start creates the ballast file. Uses os.O_CREATE|os.O_TRUNC|os.O_WRONLY so repeated invocations do not pile up multiple ballasts. The body is sparse when supported by the filesystem (Truncate) and otherwise written in 1 MiB chunks.

func (*DiskFullSimulator) Stop

func (d *DiskFullSimulator) Stop() error

Stop removes the ballast file. Returns nil if the file was already gone.

type Fault

type Fault struct {
	// ID is the chaos_runs row primary key. Zero before persistence; set by
	// (*Store).Insert and copied back into the file mirror so the daemon-side
	// controller knows which row to stamp with the observed outcome.
	ID int64 `json:"id,omitempty"`

	// Type is the fault category. Unknown types are tolerated by readers.
	Type FaultType `json:"type"`

	// StartedAt is when the fault was injected. Mostly diagnostic — the active
	// window is governed by Until.
	StartedAt time.Time `json:"started_at"`

	// Until is the wall-clock time at which the fault stops applying. The
	// controller skips faults with Until.Before(time.Now()) without removing
	// them from disk; the next persistence cycle prunes them.
	Until time.Time `json:"until"`

	// Probability (0..1) of injecting the fault on each candidate event. Most
	// faults default to 1.0 (always inject) but network-flap and provider-429
	// are typically partial so retry paths actually exercise their happy path.
	Probability float64 `json:"probability,omitempty"`

	// Note is free-form text shown in `cloop chaos report` and useful when
	// running scripted chaos campaigns.
	Note string `json:"note,omitempty"`
}

Fault describes a single active fault injection.

All times are wall-clock UTC. Until == zero means "no expiry", but every public injection path requires a positive Duration so this is a defensive guard rather than a documented mode.

func (Fault) Active

func (f Fault) Active(now time.Time) bool

Active reports whether the fault is currently within its [StartedAt, Until) window. The clock-source is wall-clock by design: faults are configured by humans in calendar terms and survive process restarts via the JSON mirror.

func (Fault) Duration

func (f Fault) Duration() time.Duration

Duration returns the configured fault window length.

type FaultType

type FaultType string

FaultType identifies a kind of injected failure.

New values must be added to AllFaultTypes (used by validators) and to the switch in Apply* helpers. Readers must treat unknown values as no-ops so downgrading the binary while a fault file lingers on disk does not panic.

const (
	FaultProviderTimeout FaultType = "provider-timeout"
	FaultProvider429     FaultType = "provider-429"
	FaultProvider500     FaultType = "provider-500"
	FaultSQLiteBusy      FaultType = "sqlite-busy"
	FaultNetworkFlap     FaultType = "network-flap"
	FaultDiskFull        FaultType = "disk-full-simulation"
	FaultSlowDisk        FaultType = "slow-disk"
)

func ParseFaultType

func ParseFaultType(s string) (FaultType, error)

ParseFaultType validates s and returns the corresponding FaultType, or an error listing the valid values. Whitespace and case are normalised.

type Outcome

type Outcome string

Outcome captures how the system reacted to a fault. Recorded back to chaos_runs at the end of an injection window. Values are deliberately coarse-grained — fine analysis comes from the steps/events tables.

const (
	OutcomeRecovered Outcome = "recovered" // system continued operating
	OutcomeDegraded  Outcome = "degraded"  // continued but with errors/retries
	OutcomeCrashed   Outcome = "crashed"   // a process exited or panicked
	OutcomeUnknown   Outcome = "unknown"   // no observation collected
)

type Run

type Run struct {
	ID                 int64
	FaultType          FaultType
	Probability        float64
	StartedAt          time.Time
	StoppedAt          time.Time
	DurationMS         int64
	Outcome            Outcome
	OutcomeDetail      string
	ObservedErrors     int
	ObservedRetries    int
	ObservedRecoveries int
	Note               string
}

Run is one row of the chaos_runs table — a single fault injection together with the observed outcome.

type Store

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

Store is a thin SQLite-backed log of chaos injections. Uses its own *sql.DB rather than statedb.DB so chaos can run even when statedb itself is the subject of the fault (for sqlite-busy testing).

The handle is opened under statedb's connection policy (Task 20374): a lock is not what the store is for, so it waits one out like every other writer. Its own writes never meet its own fault — the suite and the CLI insert a run's row before the sqlite-busy holder takes the lock and finalise it after the holder has let go. The holder itself (sqlite_busy.go) opens bare, because there the lock is the point.

func OpenStore

func OpenStore(dbPath string) (*Store, error)

OpenStore opens (and migrates as needed) the chaos_runs table inside the project state database. Reuses .cloop/state.db so chaos runs travel with the rest of project history; the table itself is provisioned by migration 0004_chaos_runs.sql.

Closes any opened resources on failure so callers don't have to reach for a deferred Close on the error path.

func (*Store) Close

func (s *Store) Close() error

Close releases the underlying connection. Idempotent.

func (*Store) Insert

func (s *Store) Insert(r Run) (int64, error)

Insert appends a new row and returns the assigned ID. Use Update later to stamp the outcome once the fault window closes.

func (*Store) List

func (s *Store) List(offset, limit int) ([]Run, error)

List returns up to limit rows starting at offset, newest first. Pass 0/0 for "all rows".

func (*Store) Update

func (s *Store) Update(r Run) error

Update finalises a run by writing the stopped_at, duration, and outcome fields. Other columns are left untouched so concurrent observers can keep incrementing the counters without racing the finaliser.

type Suite

type Suite struct {
	WorkDir    string
	StatePath  string
	Controller *Controller
	Store      *Store
	Cases      []SuiteCase
}

Suite runs each case sequentially: inject → probe → clear → record. Designed to be invoked by `cloop chaos suite` against either:

  • a fresh in-process probe (the default — exercises Transport, the SQLite busy holder, and the disk simulator without needing an external cloop process), or
  • a probe that reaches into a running cloop ui via HTTP (callers can provide a custom Probe that hits /healthz, runs a task, etc).

func (*Suite) Run

func (s *Suite) Run(ctx context.Context) ([]Run, error)

Run executes every case in order and persists each result. Returns the list of finalised Run rows so the caller can render a report inline.

type SuiteCase

type SuiteCase struct {
	// Fault is injected when the case runs. Until-StartedAt is its window,
	// which Run measures from the start of the case rather than from when
	// the case was built; a Fault with no StartedAt keeps Until as given.
	Fault       Fault
	Description string
	// Probe is the function the suite runs against the system under test
	// while the fault is active. It returns the outcome and a short detail
	// string. Probes must complete inside the fault's window or the suite
	// considers the run "degraded".
	Probe func(ctx context.Context) (Outcome, string)
}

SuiteCase describes one chaos test case. The default suite covers every FaultType in turn; callers can extend or subset it via WithCases.

func DefaultCases

func DefaultCases(workDir string) []SuiteCase

DefaultCases returns one case per fault type with sensible defaults. The per-fault Probe verifies that the fault is actually being honoured (HTTP probe for the provider faults, sqlite probe for sqlite-busy, disk probe for the disk faults). This is the "self-test" the spec asks for: when the suite runs cleanly all chaos faults are demonstrably applied.

type SummaryRow

type SummaryRow struct {
	FaultType   FaultType
	Total       int
	Recovered   int
	Degraded    int
	Crashed     int
	Unknown     int
	LastRunAt   time.Time
	AvgDuration time.Duration
}

SummaryRow aggregates chaos_runs entries by fault type for the headline table that `cloop chaos report` prints.

func SummaryRows

func SummaryRows(runs []Run) []SummaryRow

SummaryRows reduces a list of runs to one SummaryRow per fault type, sorted by FaultType for stable output.

type Transport

type Transport struct {
	// Base is the underlying transport that handles non-faulted requests. nil
	// means use http.DefaultTransport, matching the convention for net/http
	// middleware. Tests typically inject an httptest.NewServer's transport.
	Base http.RoundTripper

	// Controller is consulted on every request. nil means "use the global
	// controller" — see chaos.Global. If Global() is also nil, the transport
	// is fully transparent.
	Controller *Controller
}

Transport is an http.RoundTripper that consults a Controller before dispatching each request, and replaces the response with a fault when one is active. When no controller is installed (or no faults match), it forwards the request to Base unchanged.

The hot path adds one atomic load + one type comparison per request.

Why a wrapper rather than a Client option: Anthropic, OpenAI and Ollama all build their *http.Client via provider.NewHTTPClient(), which is the single integration point. Wiring chaos into NewHTTPTransport via this wrapper means every provider — present and future — gets fault injection for free.

func (*Transport) RoundTrip

func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper. It checks every relevant fault type and short-circuits with the first one that fires; the order is fixed so behaviour is deterministic when multiple types are simultaneously active.

Jump to

Keyboard shortcuts

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