Protocol and format compatibility¶
Every place where two clink builds, or a clink build and its own older artefacts, must agree on bytes. Each domain below evolves independently and carries its own version and its own policy; there is deliberately no single "clink format version", because the domains change for unrelated reasons and a shared number would force unrelated bumps.
Two global rules hold everywhere:
- Incompatible input fails before side effects. A frame, sidecar, snapshot or plugin that this build cannot honour is refused where it is read, before any state mutation, sink commit, or job admission it would have caused.
- Additions are compatible; changes are versioned. Every encoding here has a defined way to add information that old readers skip (a tail field, an unknown key, a new frame kind); anything that changes the meaning of existing bytes needs its domain's version bumped, and the old version either handled or refused by name.
The frozen-bytes fixtures in tests/fixtures/ (exercised by
tests/test_format_fixtures.cpp) pin the persistent and wire encodings from
outside the codebase: the fixture files were written by one build and must
stay readable by every later one, so an encode/decode pair cannot silently
co-evolve past its own history.
Domain inventory¶
| Domain | Current | Minimum compatible | Where enforced |
|---|---|---|---|
| Cluster control protocol | 2 | 1 | include/clink/cluster/protocol.hpp (kClusterProtocolVersion), three handshake sites |
| Data plane (operator wire frames) | unversioned by design | n/a | include/clink/runtime/network/wire.hpp |
| Checkpoint metadata sidecar | 1 | 1 | include/clink/state/checkpoint_integrity.hpp (kCheckpointMetaVersion) |
| State snapshots and savepoints | 1 (clink.format_version) |
1 (absence reads as 1) | docs/internals/state-snapshot-format.md |
Job plan (JobGraphSpec JSON) |
unversioned, additive-only | n/a | JobGraphSpec::from_json, src/cluster/job_graph.cpp |
| Connector offsets and committables | ride the snapshot format | n/a | per-connector state encodings, docs/internals/sink-committer-framework.md |
| Plugin ABI | kAbiVersion + structural fingerprint |
exact fingerprint match | src/cluster/plugin_loader.cpp |
| Embedded C ABI | 1 (CLINK_EMBED_ABI_VERSION) |
caller-checked | include/clink/embed/clink.h |
Incident capture (.cap) |
2 (kCaptureVersion) |
header-gated | include/clink/runtime/record_capture.hpp |
| Capabilities manifest JSON | 1 (schema_version) |
additive-only within 1 | include/clink/connectors/capability.hpp |
Cluster control protocol¶
kClusterProtocolVersion = 2, kMinCompatibleClusterProtocolVersion = 1
(include/clink/cluster/protocol.hpp). Peers exchange both numbers in the
handshake messages and are compatible when the ranges overlap, so versions
may differ as long as each side can speak something the other accepts.
- Enforcement, all three directions: the coordinator refuses an
incompatible worker at
Register; the worker refuses an incompatible coordinator atRegisterAck("the coordinator can read me" does not imply "I can read the coordinator"); the coordinator refuses an incompatible client atHelloClient. Each refusal increments theprotocol_mismatchmetric, so a cluster half-refusing its peers is visible to monitoring. - Compatible additions are tail-appended fields: decoders treat an
absent tail as the field's v1 default, and a peer that declares nothing
reads as version 1 (pre-versioning peers still decode). Bump
kClusterProtocolVersiononly for a change old peers would misread. - Version 2 capability: worker heartbeats carry a tail sequence and a v2
coordinator returns
HeartbeatAck. The worker uses acknowledgements as a bidirectional control-session lease, detecting one-way partitions that do not produce TCP EOF. The new frame is sent only when the registered worker declares v2; a v2 worker paired with a v1 coordinator falls back to EOF, so the minimum remains v1 for rolling upgrades. - Known limitation: client enforcement is one-directional. The coordinator sends no hello ack on success, so a CLI never learns the coordinator's version.
- Tests:
tests/test_protocol_versioning.cpp(both refusal directions end to end, pre-versioning decode, refusal legibility);fuzz/cluster_framewith its committed-reproducer replay;tests/fixtures/register-msg-v1.binpins the handshake bytes.
Data plane (operator-to-operator wire frames)¶
Deliberately unversioned per frame (include/clink/runtime/network/wire.hpp).
Both ends of a data channel are subtasks of one job, deployed by one
coordinator to workers that were each admitted through the versioned control
handshake above, so the data plane inherits the control plane's negotiation
and never talks to a peer of unknown vintage.
- Compatible additions are new
Kindvalues: framing is length-prefixed, so an unknown kind is skippable, and a payload-layout change gets a NEW kind rather than a changed one (WatermarkIdle = 6andArrowBatch = 7are the precedents;Data = 0is retired in place as documentation). - Robustness is structural, not versioned:
kMaxFrameBytesis enforced at both read sites before allocation;ArrowBatchpayloads are self-describing Arrow IPC validated withValidateFullbefore use, and the receiver checks the embedded schema against its registered batcher. - Tests:
tests/test_network_channel.cpp(oversized frames),fuzz/data_framewith the decode-reencode round-trip property.
Checkpoint metadata sidecar¶
kCheckpointMetaVersion = 1 (include/clink/state/checkpoint_integrity.hpp).
A key=value text sidecar carrying the version, checkpoint id, payload byte
count and CRC32C, written next to the payload and fsynced before the
COMPLETED marker that makes a checkpoint eligible for restore.
- Compatible additions are new keys:
CheckpointMeta::parseskips unknown keys. A sidecar declaring a NEWER version than this build isUnsupported, never guessed at (CheckpointIntegrityTest.NewerSidecarVersionIsUnsupportedNotGuessed), and recovery falls back to the newest checkpoint that verifies. - Failure ordering: verification runs before restore and before any sink is told to commit; an unverifiable checkpoint cannot cause an external side effect.
- Tests:
tests/test_checkpoint_integrity.cpp,fuzz/checkpoint_meta,tests/fixtures/checkpoint-meta-v1.txt.
State snapshots and savepoints¶
One Apache Arrow IPC stream per snapshot, clink.format_version = "1" in
the schema metadata, absence reading as version 1. Checkpoints and
savepoints are the same format; the full contract, including the changelog
variant and the rule that readers must ignore unknown metadata keys, is
docs/internals/state-snapshot-format.md.
- User-value evolution is separate from format evolution: the
StateVersionMapstamps (clink.state_versionsmetadata) drive migrate-at-restore and the pre-deploy compatibility check, so a job can change its own state schema without the container format moving. - Tests: the snapshot round-trip and schema-evolution suites;
tests/fixtures/snapshot-v1.snapis a frozen stream that must stay loadable bystate_processor::Savepoint.
Job plan (JobGraphSpec JSON)¶
Unversioned; the policy is additive optional keys with absent-tolerant
readers, and it matters more than it looks: specs are persisted in the HA
directory, so a NEW coordinator recovering leadership must read a spec an
OLD build wrote. name, column_lineage, expected_state_versions and
determinism_coverage are the precedents - each defaults sanely when
absent. from_json validates structure and refuses malformed specs before
admission. Pinned by tests/fixtures/job-spec-v1.json.
Connector offsets and committables¶
Source offsets and sink prepared-transaction handles are operator state
inside the snapshot, so they version with the snapshot format; the bytes
within are each connector's own encoding, owned and evolved by that
connector. The commit-side restore contract (staged barrier-consistent
rows, CONFIRMED markers, idempotent re-delivery of executed commits) is
docs/internals/sink-committer-framework.md and
docs/internals/checkpointing.md; the cross-process crash-window tests in
tests/integration/ are what hold it.
Plugin ABI¶
Three exported constants (include/clink/plugin/abi_version.hpp, generated):
kAbiVersion (manual bump for semantic breaks), kAbiFingerprint (hash of
the public headers, ABI-relevant compile options and kAbiVersion), and
kAbiHash (exact git commit, informational). The loader
(src/cluster/plugin_loader.cpp) admits a plugin iff the structural
fingerprints match exactly; CLINK_STRICT_PLUGIN_ABI=1 restores exact
commit-hash matching, and a legacy plugin exporting no fingerprint symbol is
held to the strict rule rather than waved through. There is no N-1
tolerance by design: a plugin links clink_core statically, so "compatible"
means "built against identical headers".
Embedded C ABI¶
CLINK_EMBED_ABI_VERSION = 1 (include/clink/embed/clink.h). The contract
is caller-side: compare clink_abi_version() against the macro you compiled
with before using anything else. Covered by clink_c_abi_tests.
Incident capture (.cap)¶
Magic CCAP, kCaptureVersion = 2
(include/clink/runtime/record_capture.hpp); the header carries the version
and a truncated flag, and replay surfaces the file's version in
ReplayInfo::format_version. Determinism and cross-version A/B semantics
are docs/internals/replay-determinism.md.
Capabilities manifest JSON¶
schema_version = 1, first key of clink --capabilities-json
(kCapabilityManifestSchemaVersion,
include/clink/connectors/capability.hpp). Bumped only when a key is
renamed, removed or changes meaning; additions do not bump it. The build
block carries git_sha and git_clean so a manifest maps back to code.
Pinned by ConnectorCapabilityTest.ManifestJsonDeclaresItsSchemaVersionAndOrigin.