XtcpFlatRecord evolves over time (fields get added, and occasionally the format
changes in ways that matter). Because xtcp2 rolls out across the fleet
incrementally, at any moment the Kafka stream is a mix of record formats — old
daemons and new daemons producing side by side. To keep that tractable, every
record is self-describing and ClickHouse routes each row to a per-version table.
Both live in proto/xtcp_flat_record/v1/xtcp_flat_record.proto at the lowest
(single-byte-tag) field numbers, and are stamped in pkg/xtcp/deserialize.go:
| Field | # | Meaning |
|---|---|---|
schema_version |
1 | Format epoch. Stamped unconditionally from the daemon constant XtcpFlatRecordSchemaVersion (pkg/xtcp/schema_version.go). Used for routing. |
daemon_version |
2 | Build provenance (commit/date/version, from -ldflags), plumbed via XtcpConfig.daemon_version. Informational only. The version component is the hand-bumped semver in the repo-root ./VERSION file (read by nix/binaries.nix); commit/date are filled by the build (the fleet build overrides all three). |
schema_version = 0 is reserved for pre-versioning daemons: they never set the
field, so proto3 decodes it to zero. That makes 0 a free "legacy" bucket — no
change is needed on already-deployed old daemons.
Why per-row and not on the Envelope? ClickHouse's ProtobufList format maps the
row type and consumes the envelope framing itself, so envelope fields never
become columns and cannot be routed on. The version must be on XtcpFlatRecord.
schema_version and the daemon's release version (./VERSION → daemon_version)
are independent axes and must not be coupled: the release version bumps on
every build, whereas schema_version bumps only when the record format changes
enough to warrant a new physical table. Tying the epoch to the release version
would spawn a new _vN table on every release.
| Epoch | When | What changed |
|---|---|---|
| 0 | pre-2026-08 | No schema_version on the wire. |
| 1 | 2026-08/09 | Metadata blocks 1–299, enrichment block 300s, payload 1000+. Fields only added. |
| 2 | 2026-09 | Payload field names aligned to the kernel struct member spelling, the 300s enrichment block regrouped by subject, XtcpConfig renumbered. See the rename table below and the layout policy in protobuf-formats.md. |
When to bump. Any field rename or renumber bumps the epoch. Adding a field in a free slot does not: ClickHouse and Parquet map by name, so new columns simply read as default on older rows.
Wire tag unchanged unless noted. Because the ClickHouse Kafka table maps column name → proto field → tag, a same-tag rename still decodes epoch-1 bytes correctly; only the three renumbered fields are lost for epoch-1 rows during a mixed rollout.
| Epoch 2 name | Epoch 0/1 name | Tag | Kernel source |
|---|---|---|---|
enrich_socket_dest_egress_ifindex |
same | 301 → 311 | daemon-derived |
enrich_socket_dest_egress_ifname |
same | 302 → 312 | daemon-derived |
enrich_socket_dest_next_hop_asn |
enrich_socket_next_hop_asn |
321 | daemon-derived |
tcp_info_snd_wscale |
tcp_info_send_scale |
1207 | tcp_info.tcpi_snd_wscale |
tcp_info_rcv_wscale |
tcp_info_rcv_scale |
1208 | tcp_info.tcpi_rcv_wscale |
tcp_info_fastopen_client_fail |
tcp_info_fast_open_client_failed |
1210 | tcp_info.tcpi_fastopen_client_fail |
tcp_info_rttvar |
tcp_info_rtt_var |
1231 | tcp_info.tcpi_rttvar |
tcp_info_advmss |
tcp_info_adv_mss |
1234 | tcp_info.tcpi_advmss |
tcp_info_notsent_bytes |
tcp_info_not_sent_bytes |
1245 | tcp_info.tcpi_notsent_bytes |
inet_diag_cong |
congestion_algorithm_string |
1300 | INET_DIAG_CONG |
inet_diag_cong_enum |
congestion_algorithm_enum |
1301 | derived from 1300 |
inet_diag_tos |
type_of_service |
1401 | INET_DIAG_TOS |
inet_diag_tclass |
traffic_class |
1402 | INET_DIAG_TCLASS |
sk_mem_info_rcvbuf |
sk_mem_info_rcv_buf |
1502 | SK_MEMINFO_RCVBUF |
sk_mem_info_sndbuf |
sk_mem_info_snd_buf |
1504 | SK_MEMINFO_SNDBUF |
inet_diag_shutdown |
shutdown_state |
1600 | INET_DIAG_SHUTDOWN |
vegas_info_rttcnt |
vegas_info_rtt_cnt |
1702 | tcpvegas_info.tcpv_rttcnt |
vegas_info_minrtt |
vegas_info_min_rtt |
1704 | tcpvegas_info.tcpv_minrtt |
inet_diag_class_id |
class_id |
2001 | INET_DIAG_CLASS_ID |
inet_diag_sockopt |
sock_opt |
2002 | INET_DIAG_SOCKOPT |
inet_diag_cgroup_id |
c_group |
2103 → 2003 | INET_DIAG_CGROUP_ID |
The ClickHouse Locality enum label for value 2 also changed from
connected_subnet to local_subnet (matching the proto LOCALITY_LOCAL_SUBNET
and the localnet package). Values are unchanged.
One Kafka topic (xtcp) → one Kafka engine table → one materialized view per
epoch, fanning out by schema_version → per-version MergeTree tables. ClickHouse
supports multiple MVs reading one Kafka engine table, so routing stays a
ClickHouse-only concern on the single topic. DDL:
build/containers/clickhouse/initdb.d/sql/.
topic xtcp → xtcp.xtcp_flat_records_kafka (epoch-2 column names, proto order)
├─ xtcp_flat_records_v0_mv : WHERE _error=='' AND schema_version = 0 → xtcp.xtcp_flat_records_v0 (legacy)
├─ xtcp_flat_records_v1_mv : WHERE _error=='' AND schema_version = 1 → xtcp.xtcp_flat_records_v1 (epoch 1)
├─ xtcp_flat_records_v2_mv : WHERE _error=='' AND schema_version = 2 → xtcp.xtcp_flat_records_v2 (current)
└─ xtcp_flat_records_errors_mv : WHERE _error<>'' → xtcp.xtcp_flat_records_errors
xtcp.xtcp_flat_records = Merge('xtcp', '^xtcp_flat_records_v[0-9]+$') -- cross-version query surface, AS _v2
xtcp_flat_recordsis a read-onlyMergeview spanning every_v[0-9]+table, so existing queries/dashboards that hitxtcp_flat_recordskeep working and transparently span all versions. Its_tablevirtual column tells you which physical version a row came from. It is declaredAS _v2(the newest, superset column set); a column an older table lacks reads as default for that table's rows, so branch onschema_versionwhen a renamed column matters (epoch-0/1 rows keep their data under the old names in_v0/_v1)._v1is createdAS _v0(epoch 1 only added fields)._v2has its own full DDL because epoch 2 renamed columns.- MV → table mapping is by column NAME, not position (a
TOMV is anINSERT ... SELECT)._v2_mvtherefore uses the shortfromUnixTimestamp64Nano(timestamp_ns) AS timestamp_ns, * EXCEPT (timestamp_ns)form, while_v0_mv/_v1_mvcarry an explicit select list aliasing every renamed column back to the old name (tcp_info_rttvar AS tcp_info_rtt_var,inet_diag_cgroup_id AS c_group, …). The two Enum columns pass through astoUInt8(...)so the insert does not depend on the target's enum labels.
The Kafka table decodes against the epoch-2 schema
(format_schemas/xtcp_flat_record.proto). For rows produced by epoch-1 daemons:
- renamed-only fields keep their tag → decode into the epoch-2-named column → the
_v1MV aliases them back → no data loss; - the three renumbered fields (
enrich_socket_dest_egress_ifindex/ifname,c_group) are unknown tags → dropped;_v1reads them as0/''for rows produced after the ClickHouse migration until the daemon fleet is on epoch 2. Roll daemons promptly after migrating ClickHouse.
- Change the record format in the proto;
nix run .#regen-protos. Every payload field (tag ≥ 1000) must carry its kernel-source trailing comment (go run ./tools/proto-field-auditenforces this). - Bump
XtcpFlatRecordSchemaVersioninpkg/xtcp/schema_version.go(append to its history comment) and the guard inpkg/xtcp/deserialize_test.go. - In
build/containers/clickhouse/initdb.d/sql/:xtcp_xtcp_flat_records_kafka.sql: Kafka table = the new column set, in proto order.xtcp_xtcp_flat_records.sql: addxtcp_flat_records_vN. UseCREATE ... AS _v(N-1)only if nothing was renamed; otherwise write the full DDL. Re-declare theMergeviewAS _vN.xtcp_xtcp_flat_records_mv.sql: add_vN_mv(WHERE schema_version = N,* EXCEPT (timestamp_ns)form). If columns were renamed, rewrite every older_vM_mvwith an explicit alias list mapping new → old names.
- Add
build/containers/clickhouse/sql/migrations/vN.sqlfor existing deployments (drop/recreate the Kafka table + MVs, create_vN, re-declare the Merge view);v2.sqlis the template. - Mirror the field set in
ParquetRow(TestS3ParquetSchema_matchesProtofails otherwise) and update this document's history/rename tables.
Old records keep flowing to their existing _vN table; new records land in the
new one. Because ClickHouse maps columns by name and absent proto3 scalars
default to zero, a table can also simply tolerate a mixed fleet within one epoch
(new columns read empty on old rows) — the per-version split is for changes big
enough to warrant a physically separate table.
Nothing new is required on the host: schema_version is compile-time and
daemon_version comes from the existing build -ldflags. The versioned DDL ships
inside the ClickHouse image build; a fleet image rebuild (runpod/xtcp2) carries the
new binary + DDL. Existing ClickHouse deployments apply
build/containers/clickhouse/sql/migrations/v2.sql (after copying the regenerated
format_schemas/xtcp_flat_record.proto into the server's format_schemas/
directory). ansible-host needs no change for this feature.