Exactly-once Kafka topic+partition mirroring to Kafka, Filesystem, or S3, in one deployment.
Status: feature-complete for the
checkit/mirror-v3cutover: Kafka source + Kafka/Filesystem/S3 sinks,/cache/v1HTTP surface, kkv-v1 notify webhooks, committed-offset delivery semantics and sticky readiness (as kafka-keyvalue). See AGENTS.md for the development history.
- One process can run N parallel mirrors, each pinned to exactly one source
(topic, partition). - A shared destination block configures the sink; each mirror may override the destination name.
- Three destination types:
kafka— produce to another Kafka-compatible broker (parity with the legacy Java worker).filesystem— write atomic, offset-named files to a local directory.s3— same model against any S3-compatible endpoint (AWS S3, VersityGW, etc.).
- For
filesystem/s3, file/blob names encode the sourcefrom–tooffset range and are the source of truth for destination state on restart. - Configurable flush triggers per blob destination: max time, max bytes, max offsets — whichever trips first.
The single non-negotiable: restart correctness derives from the destination, not from a local checkpoint. On startup, the mirror inspects the destination, computes the next expected source offset, and seeks the source consumer there.
mirror-v3 validate --config config.yaml # parse-only
mirror-v3 run --config config.yaml # start the configured mirrors
mirror-v3 status --config config.yaml # one-shot health check, table format
mirror-v3 status --config config.yaml --format json # same, machine-readable
mirror-v3 restore --config config.yaml --mirror <name> --offsets preserve|renumber ... # see Restorestatus queries the source Kafka high watermark and the destination's next-expected-offset for every mirror in the config and prints the lag. Exits non-zero if any mirror failed to query (unreachable broker, corrupt destination chain, etc.). Useful as a kubectl exec health probe before/during/after an appliance backup, without having to ssh to the node.
All logs go to stderr (heartbeat, flush lines, errors). stdout is reserved for command-driven output (status --format json, the validate success line). Standard 1> / 2> redirects work as expected.
mirror-v3 run starts an HTTP server on 0.0.0.0:9090 that serves Prometheus-format metrics at /metrics. Override the port with MIRROR_V3_METRICS_PORT=<port>. A bind failure (port in use) is a startup error. Every MIRROR_V3_* variable is read once at startup; unset means its default, and a value that does not parse is a startup error naming it.
Every metric carries topic="<source-topic>" and partition="<n>" labels so they join cleanly with broker-side exporters (kafka_exporter, kafka-lag-exporter). The metrics every mirror has (mirror_v3_destination_*, mirror_v3_source_*, mirror_v3_mirror_restarts_total, mirror_v3_cache_*) also carry mirror="<name>": one process may run two mirrors of one partition, a cache and a backup of the same topic, and their series would otherwise overwrite each other. Joins on (topic, partition) keep working (group_right below). The mirror_v3_notify_* metrics carry topic and partition only.
| Metric | Type | Description |
|---|---|---|
mirror_v3_destination_offset_verified |
gauge | Next source offset the destination would accept; everything below this is durable. Set on startup and advanced by the sink the moment it confirms a commit — acks=all produce-delivery for Kafka, rename(2) success for Filesystem, PutObject success for S3. This is the load-bearing metric for "how much is safe right now". |
mirror_v3_mirror_restarts_total |
counter | Transient failures of a mirror without cache or notify (its source or a destination could not be reached: S3 down, a broker away), each followed by opening it again in the process after a backoff. A climbing value is the "destination is having problems" signal. |
mirror_v3_destination_records_total |
counter | Records that crossed the gate, since process start. |
mirror_v3_destination_last_flush_timestamp_seconds |
gauge | Unix timestamp (seconds) of the most recent flush. PromQL time() - mirror_v3_destination_last_flush_timestamp_seconds gives "seconds since last flush". Filesystem / S3 only. |
mirror_v3_destination_bytes_total |
counter | Cumulative bytes written to the destination by Filesystem / S3 sinks. |
mirror_v3_destination_flushes_total |
counter | Number of flushes by Filesystem / S3 sinks. |
Useful PromQL:
# A destination keeps failing (its mirror is being reopened in the process)
increase(mirror_v3_mirror_restarts_total[10m]) > 0
# Seconds since last flush — alert if > flush.max-time-ms / 1000 × 2
time() - mirror_v3_destination_last_flush_timestamp_seconds
# End-to-end lag (join with kafka_exporter's source watermark on topic+partition):
kafka_topic_partition_current_offset
- on(topic, partition) group_right mirror_v3_destination_offset_verified
A minimal PodMonitor for the checkit chart points at port 9090; the standard process metrics (process_cpu_*, process_open_fds, …) are also exposed by the exporter.
run spawns one task per mirror, each pinned to one (topic, partition). SIGINT/SIGTERM trigger a graceful shutdown that waits for every mirror to flush its buffered records before exiting zero. A failure of a mirror with a cache (http-access) or notify ends the whole process with a non-zero exit, and the orchestrator (k8s) restarts it; a mirror with neither is opened again inside the process after a backoff (1 s doubling to 60 s, mirror_v3_mirror_restarts_total) when its source or a destination could not be reached, since it holds no state but its destinations, so a destination outage does not take the process's caches down. Any other failure of such a mirror (an offset mismatch, an object it did not write, a corrupt chain, a lost source position, a record or a configuration that cannot work) repeats on every attempt and ends the process too, so it shows as a crash loop.
Per-mirror opt-in via http-access: { cache-v1: {} }. When at least one mirror has it set, mirror-v3 run starts a second HTTP server on 0.0.0.0:8080 (override with MIRROR_V3_CACHE_PORT) that exposes the KKV-shaped surface under each opt-in mirror's name:
GET /cache/v1/{mirror}/raw/{key} → value bytes (application/octet-stream), 404 if absent
GET /cache/v1/{mirror}/offset/{topic}/{partition} → decimal text
GET /cache/v1/{mirror}/keys → newline-separated keys
GET /cache/v1/{mirror}/values → newline-separated raw values
Each mirror owns its own key → latest-value view; a key only shows up under the mirror that consumed it. Reads carry x-kkv-last-seen-offsets: <JSON> and return 503 until that mirror has caught up to its source's high watermark at startup, and never again afterwards (sticky, as kafka-keyvalue; see Readiness), so dependents don't see a partially rebuilt state. The view updates per-record from the consume loop, decoupled from disk flush cadence (set flush.max-time-ms high to save bucket ops without sacrificing freshness). Updates are monotonic; if a future feature ever rewinds source consumption, the cache stays at the highest offset seen.
To keep existing kkv consumers working unmodified during a migration, one mirror per process may additionally set cache-v1-main: {}. That mounts the unprefixed /cache/v1/... paths onto that mirror's view (alias-only — same handlers, no separate data path). The validator rejects more than one cache-v1-main in the config. Mirror names that collide with the literal path segments raw | offset | keys | values are rejected.
Also exposed on the same port:
POST /_admin/v1/shutdownandPOST /_admin/v1/shutdown/{exitcode}— request graceful exit.GET /openapi.jsonandGET /openapi.yaml— auto-generated OpenAPI 3.1 spec; the committed copy is atschemas/mirror-v3.cache.openapi.json(gated bycargo run -p xtask -- check-openapi).GET /docs— Scalar UI rendering the spec.
Bootstrap: a cache is built by reading the source topic from its low watermark, as kafka-keyvalue does, never from a destination. A cache does not need S3 to start and is not stalled by a slow destination when it is the only thing its mirror does, and a blob destination's startup reads object names only. On a mirror that has destinations too, the source is read from the low watermark and the destinations skip what they already hold. Put a cache in its own mirror when its availability must not depend on a destination: a cache mirror's failure ends the process, a destination's failure included. Whether a cache should share a mirror with destinations at all is open; see Open design questions.
mirror-v3 restore reads one mirror's blob backup (S3 or filesystem) back into a Kafka topic: disaster recovery from the bucket alone, after the cluster that wrote it is gone.
# Is the backup complete? Reads and decrypts every object, prints a summary, produces nothing.
mirror-v3 restore --config mirror-v3.yaml --mirror operations-backup --offsets preserve --verify-only
# Restore it into an empty topic.
mirror-v3 restore --config mirror-v3.yaml --mirror operations-backup --offsets preserve \
--bootstrap-servers kafka:9092 --topic <topic>| Flag | |
|---|---|
--config, --mirror |
The config and mirror that wrote the backup. The backup is that mirror's destination: its directory (<prefix>/<name>/<partition>/, or <root>/<name>/<partition>/), format, keys/values, encryption keys-dir and credentials.read are used as they are. The whole config is loaded and validated as for run, so its environment variables must be set, and the mirror's topic must be the topic the backup was taken of (every record is checked against it). |
--destination |
The destination to read, by name; required when the mirror has more than one. A Kafka destination or a compaction: log mirror (whose objects are snapshots, not the topic's records) is refused. |
--offsets |
preserve or renumber, required: see below. |
--chain-start |
The offset the backup starts at, default 0. A chain that starts anywhere else is an error unless this says so, so objects removed from its head are never missed silently. |
--verify-only |
Stop after the verify pass. |
--bootstrap-servers, --topic |
The target: partition partition (the mirror's) of an existing, empty topic (with --follow, empty or followed before). |
--follow |
Keep the target restored as the backup grows, until SIGTERM or SIGINT: see Continuous restore. |
--poll-interval-ms |
With --follow: how often to list the backup once every object is restored, default 5000, at least 100. |
Every run first lists the backup and validates the chain of object names (sorted, no gap, no overlap, starting at --chain-start, every key id it names present in keys-dir), then reads every object and checks it against its name (records in increasing offset order inside <from>-<to>, the last at to) and the mirror's topic and partition. It prints the summary to stdout:
backup: s3://<bucket>/sites/<site>/operations/0 (mirror operations-backup, destination operations)
source: <topic>/0
objects: 3
offsets: 0-24999
records: 25000
holes: 0
holes are positions of the chain without a record (see Offset holes): records compaction removed before they were backed up, and transaction markers. Every transaction marker takes an offset, so a topic written in transactions has holes on either broker (Apache Kafka writes a marker after each transaction, Redpanda also one before it, so on Redpanda from offset 0, even after one transaction) and restores only with renumber. Any failed check exits non-zero with the reason, and so does --verify-only when the backup cannot be restored with the --offsets given; --verify-only is the scheduled "is the backup complete" check.
Then the records are produced in order, each with its key, value (tombstones included), headers and timestamp, many in flight, every key and value checked against the mirror's keys/values first as the mirror's Kafka destination does: the producer is idempotent (it retries without duplicating or reordering a record), and the broker must report every record stored at exactly the offset it was sent for, so another writer on the partition, or a record lost or written twice, ends the restore. Before each object is produced it is read again and must be byte for byte the object the verify pass read. The target must be empty (high watermark 0) before the first record, and its high watermark must equal the records produced after the last; otherwise restore exits non-zero. Records sent before a failure may still be stored, so after one the topic is deleted and created again before restoring again. This is not the mirror's per-record high-watermark gate, which a restore does not need: it does not resume, so the gate's guarantee (a restart produces nothing twice) has nothing to protect, and producing fast keeps the window for a failure short. Against a local Redpanda, a million records of about 100 bytes restore from S3 in about 5 s, the verify pass included; with the per-record gate the same would take about two hours.
The two offset modes:
preserve: every record at its original offset. For topics whose readers key on offsets, such as an operations topic that a downstream index keys rows on and resumes fromMAX(offset). A Kafka topic starts at 0 and cannot be written with holes, so a backup that starts after 0 or has a hole is refused before anything is produced, naming the first hole. After apreserverestore the topic continues the backup's chain exactly, so the backup mirror resumes on the same prefix where it stopped.renumber: the records at 0, 1, 2, ... in their original order, for compacted topics (user-states), where holes are expected and keys, values and order are what matter. What changes: every offset from the first hole on (the topic is shorter than the chain by the number of holes), so committed consumer-group offsets and anything else that stores offsets of the old topic do not apply to the new one. Superseded values and tombstones are produced too; the broker compacts the restored topic by its owncleanup.policy. The backup's chain now ends past the restored topic's high watermark, so a backup mirror of the restored topic must write a new chain (another destinationnameorprefix): on the old one it stops with "the destination is ahead of the source".
The broker refuses a record batch larger than the target topic's max.message.bytes (a limit on the batch, not on a record). Restore reads the topic's effective value at start (the topic's own, or the broker default it reports) and logs it, and the producer's batches are as large as that limit allows, not the producer's default batch size (1000000 bytes): under a lower limit the restore sends more, smaller batches, down to one record each, and the restored offsets and order are the same. A record that is larger than the limit as a batch on its own cannot be restored into that topic: restore stops before sending it, naming the topic, the offset and the sizes. At the end it logs the batches sent (produced in batches: their number, the largest in bytes and in records).
In both modes a record's timestamp is produced as its CreateTime, also for a record whose source topic stamped LogAppendTime; a target topic with message.timestamp.type=LogAppendTime restamps every record at restore time. A record without a timestamp gets the producer's clock. Time-based retention counts from these timestamps: a target topic whose retention.ms is shorter than the age of the oldest restored records deletes them soon after the restore, as their segments roll and expire. Give the target a retention that covers the backup's age (retention.ms=-1, or longer than the oldest record) before restoring, and lower it later if it should apply from now on.
Restore does not create the target topic (its partitions, cleanup.policy and retention are the operator's decision) and does not resume: a restore that fails part way leaves a topic that the next run refuses as not empty, so delete and create it again and rerun.
--follow reads the backup as a mirror's source: run_mirror with the Kafka destination, its per-record gate (the high watermark read before every produce, no retries, the offset the broker reports checked) and its idle drift check, so nothing else may write the target. It starts at the target's high watermark, and after every object listed so far it lists the backup again every --poll-interval-ms, producing the objects the backup's mirror has added, so the target trails the source by about the backup mirror's flush interval: a standby, or a topic moved between clusters through the bucket. It runs until SIGTERM or SIGINT (also during its startup) and can be stopped and started again at any time; it is not a replacement for the one-shot restore when the backup is all there is, since the gate makes it slow (about 150 records/s against a local broker), so restore the bulk with the one-shot restore and follow from there.
- Resume: a target that is not empty holds the backup's first records, in both offset modes (with
renumber, the n-th record of the chain is at offset n), so a follower continues at its high watermark without any state of its own. Before it does, it reads the target's last record and requires it to be the backup's record at that position (key, value, headers, and the timestamp unless the target stamps LogAppendTime). Only that record is compared: this refuses a topic that holds another topic's records, not one whose earlier records were changed. A target whose last record retention or compaction has removed cannot be checked and is refused: give a followed topic a retention that keeps its records (see the timestamps above). Withrenumber, finding the position reads the objects before it once at start. A one-shot restore and a follower can therefore hand over: follow a topic that a one-shot restore filled, with the same--offsets. - Checks: every object is read and checked as in the verify pass before its records are produced, and new objects must continue the chain. A filesystem backup directory that does not exist is refused, as by the one-shot restore (the mirror creates it when it opens); a directory or S3 prefix that exists and holds no objects yet is waited for, and every listing logs that it is empty and for how long, so a wrong prefix shows in the logs. There is no verify pass of the whole backup up front, so with
preservea hole stops the follower when it is reached, with the records before it restored;--verify-onlychecks a backup before following it. A gap, a hole, a target past the backup's end or another topic's records end the follower (exit 1). An unreachable store or broker does not: the follower is opened again in the process after a backoff from 1 s doubling to 60 s, asrunopens a mirror again, and resumes at the target's high watermark.
What disaster recovery needs besides the bucket:
- An S3 identity that may list and get objects under the prefix, in the variables
credentials.readnames. A least-privilegereadidentity that only lists (the mirror needs no more in append mode) cannot restore. Restore opens the store read-only: thewriteidentity's variables need not be set. - Every Parquet key the objects name (
.k-<key id>.parquet), inkeys-dir. A key kept only on the lost machine makes its objects unreadable: keep the keys where the bucket's backups can be restored without that machine.
The default INFO-level log stream is operator-oriented:
- One line per mirror at startup with the resolved destination type and source seek.
- A heartbeat line every 30 s with
expected_offsetandprogressed(records since the last heartbeat). Confirms liveness even when the source is idle. Override the interval withMIRROR_V3_HEARTBEAT_SECS=<seconds>; set to0to disable. - One line per flush for Filesystem and S3 sinks:
path,from,to,count,bytes,elapsed_ms(how long this flush took),interval_ms(since the previous flush). Kafka sinks don't buffer so they have no flush line — the heartbeat carries the "still alive, here's the offset" signal.
RUST_LOG=info is the default; RUST_LOG=mirror_core=debug,mirror_fs=debug adds verbose internals.
mirror-v3 validate --config config.yaml parses your YAML and exits non-zero on any problem.
A minimal Kafka→Kafka config:
# yaml-language-server: $schema=./schemas/mirror-v3.config.schema.json
mirrors:
- name: operations
source:
bootstrap-servers: kafka-source:9092
topic: operations-v1
partition: 0
destinations:
- type: kafka
bootstrap-servers: redpanda:9092Each mirror declares destinations: [...]. With more than one destination, one source consumer fans every record to all destinations through a tee (mirror_core::TeeSink) — the source broker is read once per record, and each destination keeps its own end-offset gate and flush cadence. See examples/dual-write-fs-and-s3.yaml for the dual-write pattern.
An S3 destination names two identities by the environment variables holding their keys, as a least-privilege bucket setup has them: write may only PutObject, read lists the prefix (and reads the latest snapshot in compaction mode). Nothing else in the environment changes the client (no AWS_* discovery), and a missing variable is a startup error:
- type: s3
endpoint: http://versitygw.observability-s3:7070
region: example-region
bucket: mirror-userstate
credentials:
write: { access-key-id-env: S3_WRITE_ACCESS_KEY_ID, secret-access-key-env: S3_WRITE_SECRET_ACCESS_KEY }
read: { access-key-id-env: S3_READ_ACCESS_KEY_ID, secret-access-key-env: S3_READ_SECRET_ACCESS_KEY }
encryption: noneencryption is required on every S3 destination, so clear text is a written decision: encryption: none, or Parquet modular encryption with keys in the observability compactor's layout:
encryption: { keys-dir: /etc/mirror-v3/parquet-keys, key-id: k1 }
format: parquet # required, written outkeys-dir is a directory, normally a mounted Secret, with one file per key: the file name is the key id ([a-z0-9][a-z0-9-]*, at most 32 characters), the content the standard base64 of 32 random bytes (openssl rand -base64 32); dot entries are skipped, anything else invalid stops the mirror (errors name the file, never its content). New blobs are encrypted with key-id (footer and every column, AES-GCM, encrypted footer, so no statistics leak) and named <from>-<to>.k-<key id>.parquet; a blob is read with the key its name carries. Rotation: add the new key's file, then change key-id; keep old keys while blobs need them. A chain may mix plain and encrypted blobs (turning encryption on). DuckDB reads one key's blobs with:
PRAGMA add_parquet_key('k1', '<base64 of the key>');
SELECT * FROM read_parquet('s3://bucket/userstate/0/*.k-k1.parquet', encryption_config = {footer_key: 'k1'});More examples: examples/.
The full schema is committed at schemas/mirror-v3.config.schema.json. Editors with a YAML language server (VS Code's redhat.vscode-yaml, Neovim, etc.) pick up the # yaml-language-server: $schema=… comment and provide completion + validation as you type.
Config values can reference environment variables using the same syntax as Yolean/y-cluster's envsubst:
mirrors:
- name: ${MIRROR_NAME:-orders}
source: { bootstrap-servers: ${SOURCE_BROKER} }
topic: ${TOPIC:-orders}
partition: 0
destinations:
- type: s3
region: ${AWS_REGION:-us-east-1}
bucket: ${BUCKET_PREFIX:-yolean-mirror}-${AWS_REGION:-us-east-1}
credentials: { write: { … }, read: { … } }${VAR} is required (fails to start if unset), ${VAR:-default} falls back to default, and $$ escapes to a literal $. Substitution is single-pass — expanded values are not re-scanned. See examples/env-interpolation-dual-write.yaml for the DRY pattern across duplicated destinations.
cargo build --release
cargo test --workspaceThe toolchain comes from rustup in the user's home, as rust-toolchain.toml pins it: install
rustup from https://rustup.rs (no system package). rdkafka builds librdkafka from source with
CMake (cmake-build), which needs a C and C++ toolchain, and librdkafka 2.12 compiles in libcurl
for OIDC whatever its options say. On Ubuntu 24.04, the build and test packages are the
Dockerfile's builder set, plus zlib for gzip-compressed topics:
sudo apt-get install -y cmake make g++ pkg-config \
libcurl4-openssl-dev libssl-dev libsasl2-dev libzstd-dev liblz4-dev zlib1g-devA container image is built via the multi-stage Dockerfile (builder = rust:1-bookworm, runtime = gcr.io/distroless/cc-debian12):
docker build -t mirror-v3:dev .
docker run --rm -v "$PWD/examples:/cfg" mirror-v3:dev validate --config /cfg/kafka-to-kafka.yaml- One process owns at most one mirror per
(topic, partition). Run withreplicas: 1and eitherstrategy.type: RecreateorRollingUpdatewithmaxSurge: 0andmaxUnavailable: 1for every mirror-v3 deployment. This is non-negotiable on two counts:- Destination races. Two writers will race on destination naming and trip the corrupt-chain detector on the next restart.
- Source-side coordination. mirror-v3 uses
assign()instead ofsubscribe()for its Kafka consumer, so there is no consumer-group coordinator deciding which pod owns the partition. Two pods up at once would both consume the same partition and race the consumer-offset commit log.
- VersityGW specifically:
If-None-Match: *is silently ignored (v1.4.1, POSIX backend, verified in e2e), so the deployment guarantee is the only atomicity layer for the cross-process race. AWS S3 honorsIf-None-Match: *and gives API-level atomicity on top of the deployment guarantee. - An unrecoverable error ends the process. Restart correctness is the recovery mechanism: the orchestrator restarts the process and every position comes from the destination again. The one exception is a destination-only mirror whose source or destination could not be reached: it is reopened in the process after a backoff, which re-derives its position from the destination the same way.
- For blob destinations, a
(from, to)filename/key is the durable "offset" — atomic rename (FS) or single-shotPutObject(S3) makes it visible. The destination listing is the source of truth on startup. - Offset holes. Kafka leaves holes in a partition's offsets where compaction removed records and at transaction markers. A blob object covers consumer positions:
fromis the previous object'sto+ 1 andtois the last record it holds, so the chain of names stays contiguous across holes and each record carries its true offset. A Kafka destination cannot reproduce a hole (its next offset is always its high watermark), so a hole in its source ends the mirror with an error saying so: mirror such topics to blobs. A position the broker no longer has (retention deleted records the mirror never read) is an error, never a jump to the earliest offset.
GET /q/health/ready returns a structured JSON body in every state:
{
"ready": "ready" | "warming",
"mirrors": [
{
"name": "userstate",
"gates_readiness": true,
"caught_up": true,
"status": "ready" | "warming" | "lag_behind_source"
| "source_unassigned" | "destination_lagging",
"source": {
"topic": "userstate", "partition": 0, "assigned": true,
"end_offset": 12345, "last_applied_offset": 12345, "lag": 0
}
}
],
"unhealthy": []
}HTTP status is 200 once every mirror that serves /cache/v1 (gates_readiness) has caught up to its source's high watermark at startup, and stays 200 (sticky, as kafka-keyvalue); 503 before. A moment's lag after that is not an outage: it is the status/lag fields here and the mirror_v3_source_lag_offsets metric. Mirrors without http-access are listed but do not gate: a blob mirror waiting for S3 does not take the cache out of its Service. The drop-in @yolean/kafka-keyvalue Node client only inspects the status code.
GET /q/health/live answers 200 while the process serves HTTP.
Per-mirror /cache/v1/{mirror}/... routes return the matching mirrors[i] element as the 503 body before their mirror has caught up.
Tuning:
MIRROR_V3_READINESS_LAG(default0) — offsets of lag tolerated before the body'sstatussayslag_behind_source(it does not change the HTTP status).MIRROR_V3_READINESS_POLL_MS(default2000) — how often each mirror's broker high-watermark + consumer assignment is re-checked.0disables the poller.MIRROR_V3_OFFSET_COMMIT_INTERVAL_MS(default5000) — how often the supervisor commits the consumer's progress back to the broker.0disables (the mirror still works but loses the between-pods notify guarantee on the next restart).
Per-destination opt-out:
destinations:
- type: filesystem
root: /var/lib/mirror-v3
# affects-readiness: true # default
- type: kafka
bootstrap-servers: ghost-cluster:9092
affects-readiness: false # best-effort secondaryA destination with affects-readiness: false still records its flushed_through for observability but is skipped when computing DestinationLagging. Use it for observability replicas or archival sinks that must not flip consumer-pod readiness when they fall behind.
The config accepts a cache (http-access) and destinations in one mirror. Such a mirror reads the source from its low watermark for the cache, and lowers the tee's resume position to it, so every record below each destination's own position is read and skipped for that destination (a resume floor; notify re-delivery after a restart uses the same mechanism from the committed offset). What stands out:
- The destinations no longer set the source position. Restart correctness still derives from them: each is listed or queried at open, skips what it holds, and only accepts the record at exactly its next offset. But the guard against a destination that is ahead of its source (a recreated or truncated topic) has to look past the floor, at the furthest destination (
Sink::furthest_next_offset); compared with the floor it would never fire for this shape. - The cache and the destinations fail together. A cache mirror ends the process on any failure, so an S3 outage on its backup takes
/cache/v1and its notifications down with it, and the pod cannot start while the destination is unreachable. A destination-only mirror is reopened in the process instead. - The cache is only as complete as the topic. It holds what the source still retains, not what the destination archived (before 0309bc8 a cache was bootstrapped from the destination's compaction-mode snapshot instead).
The alternative is the split shape in examples/kkv-and-encrypted-backup.yaml: a cache (and notify) mirror without destinations, and a backup mirror of the same partition with its own consumer group, whose only startup input is its destination. The validator could then reject http-access on a mirror with destinations. That is not done, because notify on a mirror with destinations requires cache-v1-main on the same mirror, so it would also make trigger.on: destination-flush impossible to configure and remove notify re-delivery through the tee, and whether those are wanted is the other half of the question.
kafka-keyvalue ran 2 to 6 replicas per target Service and each pushed every update, so a push one replica lost (a consumer pod restarting, not Ready yet, or answering an error) was usually healed by a sibling's. A kkv-v1 push is one-shot and the consumer acknowledges nothing beyond the HTTP status. mirror-v3 runs one replica, so it has no sibling to heal a lost push.
Delivery today (WEBHOOKS.md): a batch is POSTed to every resolved address in retry rounds that re-resolve the address set, batches are dispatched one at a time, and the outcome table decides the end: skip logs and drops the batch for that address (that consumer serves the key stale until it changes again), fail ends the process (the cache with it) after the retry budget, and a slow batch holds back the consume loop on the max-records path.
2f3be23 tried "deliver per target address, off the consume loop, until accepted": each address kept its own undelivered keys and got them retried until it accepted or left discovery for 30 s, never blocking the consume loop, with the committed offset at the lowest offset some address had not accepted. It was reverted (40cb69a) for what that cost: retries were pinned to one pod IP, so with fail outcomes one consumer pod rolling away could exhaust the budget and end the process, and a pod that answers 4xx for good (one that carries the target label but serves no POST route) held the committed offset back indefinitely.
Open: whether one replica should deliver more reliably (per address, off the consume loop, but re-resolving each attempt and bounding what a missing or refusing address can hold back), or whether the cache and notify mirror should run as more than one replica as kkv did. A mirror without destinations writes nothing, so the single-writer invariant does not bind it, but its replicas would need consumer groups of their own (the committed offset is where notify re-delivery resumes) and the backups would move to a separate single-replica deployment.