Documentation
¶
Overview ¶
Package conformance proves the invariants every integration must hold.
It knows the invariants and nothing about any integration. An integration supplies a subject: how to build it, how to make its destination stop answering, how to bring it back, and how to read what it delivered. The harness runs one sequence and judges each invariant separately, so each gets its own subtest, its own marker, and its own cell in the matrix.
Evidence is emitted, not inferred. A passing subtest logs
COVERS invariant=<id> integration=<id>
which the coverage generator reads out of `go test -json` output. Test names carry nothing here, because the same code proves the same invariant for every integration and only the marker knows which one this run was.
Invariants are declared in docs/coverage/invariants.yml. Adding one here without declaring it there reports as an unknown marker rather than as a cell.
Index ¶
- Variables
- func CanonicalKey(dt arrow.DataType) string
- func LatticeArray(key string, null bool) (arrow.Array, error)
- func LatticeKeys() []string
- func LatticeListWithNullElement(key string) (arrow.Array, error)
- func LatticeString(v string) arrow.Array
- func Pipelines(t *testing.T, s PipelineSubject)
- func Sinks(t *testing.T, s SinkSubject)
- func Types(t *testing.T, s TypeSubject)
- type PipelineSubject
- type Proxy
- type Recorder
- type RecordingOffsets
- type RecordingTx
- type Row
- type SinkSubject
- type Trigger
- type TypeDestination
- type TypeSubject
Constants ¶
This section is empty.
Variables ¶
var Triggers = []Trigger{ TriggerBatchFull, TriggerInterval, TriggerSourceClosed, TriggerDrain, }
Triggers is every path in the order the harness runs them.
Functions ¶
func CanonicalKey ¶
CanonicalKey is the docs/coverage/lattice.yml key for an Arrow type.
It drops what a declaration must not depend on: a child field's name, its nullability, a decimal's precision and scale, and a timestamp's zone. It keeps what changes a sink's behaviour: the width, the time unit, and the element type of a container.
func LatticeArray ¶
LatticeArray builds a one-row array of the key's type. The caller releases it.
A null row and a valued row are the same call, because type.null asks the same question of the same type: what the destination holds afterwards.
func LatticeKeys ¶
func LatticeKeys() []string
LatticeKeys returns every key the value table can build, sorted.
func LatticeListWithNullElement ¶
LatticeListWithNullElement builds a one-row list holding [value, null, value]. The caller releases it.
This is the third null position, and the one no published table mentions. A null column and a null list are both visible to anyone who looks; a null inside a list is not, and ClickHouse's Array(T) has no way to hold one.
func LatticeString ¶
LatticeString builds a one-row utf8 array holding v. The caller releases it.
It exists for the column entries that declare their own value. A UUID, a Decimal or an Enum column reparses text, so the pair needs content the key's canonical value cannot supply: the canonical utf8 value is the hostile string type.string.fidelity needs, and no UUID column will take it.
func Pipelines ¶
func Pipelines(t *testing.T, s PipelineSubject)
Pipelines proves every pipeline invariant the subject can exercise.
func Sinks ¶
func Sinks(t *testing.T, s SinkSubject)
Sinks proves every sink invariant the subject can exercise.
func Types ¶
func Types(t *testing.T, s TypeSubject)
Types proves the type invariants the subject's table declares.
Types ¶
type PipelineSubject ¶
type PipelineSubject struct {
// Integration is the integrations.yml id, e.g. "pipeline.stateful".
Integration string
// KeepsState says whether this configuration commits durable state and
// offsets alongside the source position. A configuration that keeps none
// cannot prove the invariants about committing them together, and the
// registry must exempt it.
KeepsState bool
// Options builds the pipeline's options from the harness's recorder. A
// stateful configuration returns core.WithStateStore(r.Offsets(), r.Tx()).
Options func(r *Recorder) []core.TurbineOption
// NewSink supplies the destination the pipeline writes to. Nil means the
// harness discards the rows and judges the event order alone.
//
// A subject that supplies one, with ReadBack and Break, is judged on what
// the destination actually holds. That is the difference between asserting
// the loop emitted three events in an order and asserting it never
// committed offsets for a row the sink never wrote.
NewSink func(t *testing.T) core.Sink
// ReadBack returns every row the destination holds. Required with NewSink.
ReadBack func(t *testing.T) []Row
// Break makes the destination stop accepting writes, and Heal reverses it.
// With a real sink these replace the harness's synthetic flush failure, so
// the flush fails the way it would in production.
Break func(t *testing.T)
Heal func(t *testing.T)
}
PipelineSubject is one configuration of the consume loop.
type Proxy ¶
type Proxy struct {
// Addr is the host:port a sink dials instead of the destination.
Addr string
// contains filtered or unexported fields
}
Proxy is a toxiproxy container between a sink and its destination.
Break adds a timeout toxic, which holds every connection open and never answers. That is a partition rather than a refusal, and the distinction matters: a refused connection returns immediately, while a hang is what #219 fixed the Kafka sink for and what sink.flush.honours_context will need. A sink that ignores its context hangs here rather than passing quietly.
func NewProxy ¶
func NewProxy(t *testing.T, nw *testcontainers.DockerNetwork, upstream string) *Proxy
NewProxy starts toxiproxy on nw, forwarding to upstream.
upstream is "<alias>:<port>" of a container already on nw, not a host port: the proxy dials it from inside the docker network. The container is terminated when the test ends.
type Recorder ¶
type Recorder struct {
// contains filtered or unexported fields
}
Recorder is the shared event log. Every instrumented part appends to it, so the order the pipeline did things is what the harness asserts on.
func (*Recorder) Offsets ¶
func (r *Recorder) Offsets() *RecordingOffsets
Offsets returns the offset store to hand core.WithStateStore.
func (*Recorder) Tx ¶
func (r *Recorder) Tx() *RecordingTx
Tx returns the state transaction to hand core.WithStateStore.
type RecordingOffsets ¶
type RecordingOffsets struct {
// contains filtered or unexported fields
}
RecordingOffsets logs every offset save and can fail one.
type RecordingTx ¶
type RecordingTx struct {
// contains filtered or unexported fields
}
RecordingTx logs the commit and the rollback.
type Row ¶
Row is one row the destination holds, decoded by the subject through the destination's own reader. The harness compares rows by their "id" value only: what other columns a row carries is the destination's business.
type SinkSubject ¶
type SinkSubject struct {
// Integration is the integrations.yml id, e.g. "sink.clickhouse".
Integration string
// New builds the sink under test. Called once per sequence.
New func(t *testing.T) core.Sink
// Break makes the destination stop answering.
//
// Nil means the integration has nothing that can break, and the
// network-shaped invariants are skipped. integrations.yml must then
// exempt it with a reason, or the cell stays missing: the harness
// skipping and the registry excusing are two statements that must agree.
Break func(t *testing.T)
// Heal reverses Break.
Heal func(t *testing.T)
// ReadBack returns every row the destination holds. It must not go through
// whatever Break breaks.
ReadBack func(t *testing.T) []Row
// OrderedReadBack says ReadBack returns rows in the order they arrived.
//
// False is the default because a destination that cannot report arrival
// order is common and the failure is silent: an Iceberg scan returns rows
// in data-file order, and a ClickHouse MergeTree returns them in its
// ORDER BY key order, so both would satisfy preserves_order while
// preserving nothing. The harness skips that claim rather than reading a
// sorted list as evidence, and integrations.yml must carry the exemption.
OrderedReadBack bool
// Table returns a one-row table with an int64 "id" column that the
// destination accepts. The subject owns the schema because a ClickHouse
// table has the columns its DDL declared, and the harness has no DDL.
Table func(t *testing.T, id int64) arrow.Table
}
SinkSubject is what an integration hands the harness.
type Trigger ¶
type Trigger string
Trigger is what makes the consume loop process a batch.
Every one of them ends in the same three steps, and a pipeline that commits correctly on one path and not another loses rows on that path alone.
const ( // TriggerBatchFull is the common path: the batch reached batchSize. TriggerBatchFull Trigger = "batch-full" // TriggerInterval is the flush interval elapsing with a partial batch. TriggerInterval Trigger = "flush-interval" // TriggerSourceClosed is the source's stream closing with rows buffered. TriggerSourceClosed Trigger = "source-closed" // TriggerDrain is a cancelled context, which a SIGTERM arrives as. The // loop drains through a context stripped of cancellation, so this is the // one path whose flush must run after the caller gave up. TriggerDrain Trigger = "drain" )
type TypeDestination ¶
type TypeDestination struct {
// Sink writes to a destination holding a single column named "v".
Sink core.Sink
// ReadBack returns the one value that column holds, nil when it is null.
// It must not go through the sink: a destination read through the thing
// under test cannot contradict it.
ReadBack func(t *testing.T) (any, error)
// ReadBackNullElement reports whether the second element of the single
// list row is null.
//
// It exists because nil-ness cannot answer the question. A list holding a
// null element is not itself null, so ReadBack returns a value either way
// and [1, 0, 3] is indistinguishable from [1, NULL, 3] through it. Only
// the destination can say which it holds.
//
// Required of a subject whose table declares any list row.
ReadBackNullElement func(t *testing.T) (bool, error)
}
TypeDestination is one column of one type, and the way back to it.
type TypeSubject ¶
type TypeSubject struct {
// Integration is the integrations.yml id, e.g. "sink.clickhouse".
Integration string
// Declared is the integration's type table. Passed in rather than read
// here, so the harness's own tests can drive the runner with a table of
// one row and a known-wrong outcome.
Declared []coverage.TypeDecl
// Nulls is what the integration does with a null. It is one statement
// about every type: ClickHouse stores a null in a non-Nullable column as
// the column type's zero value, whatever that type is, and asserting that
// a null survives would assert against the destination's own semantics.
Nulls coverage.NullRule
// ListElementNulls is what the integration does with a null held inside a
// non-null list. It is a separate statement from Nulls because the answer
// differs: a ClickHouse column can be Nullable and its Array(T) elements
// cannot, so the column-level rule says nothing about what a list holds.
ListElementNulls coverage.NullRule
// Prepare creates a destination with a single column "v" of columnType,
// and a sink writing to it.
//
// columnType is empty for an unsupported row: no column type accepts the
// value, and the batch must fail before any destination matters.
Prepare func(t *testing.T, key, columnType string) TypeDestination
}
TypeSubject is what an integration hands the type runner.