ClickHouse observation history (initial implementation)

clickhouse-out writes live IPv4/IPv6 unicast observations directly to a local ClickHouse MergeTree table. No Kafka is needed. This first implementation is for evaluation; sustained high-rate throughput has not been qualified.

Setup

ClickHouse 25.8 or newer is required by the schema’s compact projection. The integration tests cover ClickHouse 25.8 and 26.8. Replicated/Distributed tables and schema migrations are not supported in this release. Apply schema.sql manually, with the destination database selected. The exporter checks required column types, engine and retry deduplication settings before inserting. It never creates or alters tables. Do not change the table schema while it is running.

clickhouse-client --query 'CREATE DATABASE netom'
clickhouse-client --database netom --multiquery < docs/clickhouse/schema.sql
[targets.history]
type = "clickhouse-out"
sources = ["bmp-in"]
endpoint = "http://127.0.0.1:8123"
database = "netom"
table = "events"
collector_id = "collector-a"
spool_dir = "/var/lib/netom/clickhouse"
retention_hours = 24
# username = "default"
# password_file = "/etc/netom/clickhouse.password"

Netom handles systemd’s SIGTERM, quiesces producers (up to five seconds before closing target admission to release backpressure), and waits for targets to drain. The package’s systemd service runs as netom; StateDirectory=netom supplies the writable /var/lib/netom directory. Each target needs its own spool directory. Keep its manifest and pending files together. The manifest binds a persistent stream ID to the endpoint, database, table, collector ID and encoding version. Changing that binding requires a separate spool; drain the old one using its original configuration. Credentials are not stored in the manifest. All config changes currently require a restart; Reconfigure logs a warning and retains the active configuration.

Capture and identity

Attach to bmp-in to observe its parsed output before RIB filtering/mutation; attach to rib to observe that RIB’s output instead. These are different capture boundaries. Input Roto rejects, ignored post-policy updates and unknown ADD-PATH withdrawals already dropped by the parser are absent. Raw BMP copies are ignored to avoid exporting each route twice. FlowSpec and multicast are counted as unsupported. This is an observation log, without a startup RIB snapshot, EOR coverage guarantee or reconstruction of complete historical state.

received_at maps the source’s monotonic receive timestamp to UTC using a process anchor. It is millisecond precision and is not the router’s BMP timestamp. Routes retained in an upstream queue can therefore have an older receive time. stream_id, epoch, event_seq identify an exported event. Epoch changes on each target start; sequence orders rows within that epoch, not across source threads. IDs and sequence values survive spool replay. Repeated identical announcements remain separate observations.

peer_key combines stream, epoch, session ingress and connection generation. ADD-PATH route rows carry a nullable path_id and resolve to their parent peer. A bounded cache (65,536 entries / approximately 16 MiB) shares identity JSON; connection generations live in the ingress register and survive cache eviction. Each segment repeats needed identities before their references, and repeats them for each receive-time hour. Routes carry compact keys; identity JSON contains the peer, its router when available, and collector metadata. Missing metadata is explicit and counted. There is no startup enumeration of inactive peers yet.

event_kind

Meaning

1

Announcement

2

Route withdrawal

3

Identity observed/refreshed

4

Ingress invalidation (compact; path_id restricts scope when present)

5

Family invalidation (afi/safi restrict scope)

6

Ingress reappeared

7

History starts here; no baseline

8

Clean exporter shutdown

9

Source end-of-stream

Control/identity rows have event_class=0; route rows have event_class=1. Invalidation is not expanded into millions of synthetic route withdrawals. Consumers must interpret the scope/parent metadata. A missing clean end marker means coverage may have a gap; it is not a precise count of lost observations.

Attributes, disk use and queries

raw_attrs is the authoritative binary String. Netom’s RPKI and ASN-width prefix bytes are stored separately, not included in the wire attributes. Withdrawals normally have empty attributes. as_path and as4_path expose wire AS membership, including sets/confederations; they do not merge RFC 6793 paths or identify origin AS. Preserve raw attributes when segment semantics matter. Standard communities are UInt32; extended/large communities retain 8/12-byte network-order values. Unknown attributes remain in raw_attrs. Failed derivation leaves empty/null query fields and attrs_parse_ok=0. IPv4 addresses use IPv4-mapped IPv6 representation.

Raw and array columns use ZSTD(1). Repetition is compressed without dropping observations or relying on eventually consistent attribute dictionary joins. Dictionary normalization remains a benchmark candidate. The prefix-first sparse primary index supports exact prefix/time queries; the by_peer projection stores compact query fields and row offsets, avoiding a second copy of raw attributes. Hourly partitions prune time windows. Expiry rounds up to the receive-hour end plus retention_hours; whole-part TTL drops can occur later during background maintenance. This is a retention target, not an exact deletion deadline or a ClickHouse disk quota. Budget ClickHouse storage independently from the spool.

SELECT received_at, event_kind, hex(peer_key), path_id, as_path
FROM route_events
WHERE afi = 1 AND safi = 1
  AND prefix_addr = toIPv6('::ffff:192.0.2.0') AND prefix_len = 24
  AND received_at >= now() - INTERVAL 1 HOUR
ORDER BY received_at LIMIT 100;

SELECT received_at, prefix_addr, prefix_len, event_kind
FROM events
WHERE peer_key = unhex('00112233445566778899AABBCCDDEEFF')
  AND event_class = 1 AND received_at >= now() - INTERVAL 1 HOUR
ORDER BY received_at LIMIT 100;

Use EXPLAIN indexes=1 and system.query_log.read_rows/read_bytes to verify pruning with representative data. Arbitrary AS/community membership, substring, and covering-prefix queries are not automatically indexed by this key. Avoid SELECT *, FINAL, unbounded time ranges and deep OFFSET for interactive search. Views are ordinary views; they do not duplicate stored data or give independent retention. Split tables, optional membership indexes and snapshots are later work.

Buffering and failure behavior

Setting

Default

Notes

queue_bytes

67108864

Conservative byte accounting; shared blobs count fully

spool_bytes

34359738368

Pending spool budget, with bounded frame headroom

reserve_bytes

5368709120

Also reserve at least 5% of filesystem capacity

batch_rows

100000

Includes identities/control; up to one row over threshold

batch_bytes

33554432

Uncompressed encoding threshold; one event may exceed it

flush_seconds

5

Seal partial batch after this age

retention_hours

24

Range 1–8760

request_timeout_seconds

60

Includes response processing

shutdown_seconds

30

Time allowed to drain memory to disk

For high-volume qualification, start with batch_rows=1000000, batch_bytes=268435456 and a dedicated 128 GiB spool. Current drain concurrency is one. Increase batch size before adding inserts; watch active parts and merges. Small synthetic tests do not establish sustained capacity.

Spool frames contain row-aligned RowBinary bytes compressed with LZ4, checksums and bounds. Disk synchronization runs at roughly 500 ms and at segment seal. Only sealed files are sent, using the segment UUID as insert_deduplication_token. Replay validates the file, then streams decompressed bytes without re-encoding. HTTP currently sends uncompressed RowBinary; the local LZ4 wrapper is not the ClickHouse native compressed wire format. Replay uses bounded frame buffers.

On restart, complete frames in unattempted .open files are recovered; incomplete tails are truncated. Corruption of complete frames or sealed files stops replay and leaves the file for investigation. Never edit a .ready file and retry it under the same token. Files are removed only after successful synchronous insert acknowledgement. ClickHouse outages, TOO_MANY_PARTS and schema errors retain the batch and retry with exponential backoff up to 30 seconds.

The setup SQL enables non_replicated_deduplication_window=10000. Insert block settings are fixed and batches are capped below the configured two-million-row block limit. Tokens have finite retention: replay outside the server’s deduplication window can duplicate rows. This is not exactly-once delivery. The table is append-only MergeTree, not ReplacingMergeTree; queries needing to collapse delivery duplicates can use the event ID explicitly.

The default full-buffer behavior blocks input. Backpressure can delay other targets sharing that source. If the spool fills, it seals written data for drain and waits. It does not discard oldest segments. Shutdown closes admission and tries to persist queued data; an unavailable/full disk beyond the deadline or process/power failure can lose memory-only data since the last sync. Other I/O errors stop the writer visibly; pending durable segments remain replayable. There is no drop_newest mode in this first release.

Metrics under /metrics include clickhouse_accepted, clickhouse_inserted, clickhouse_errors, clickhouse_unsupported, clickhouse_missing_identity, clickhouse_stopped, clickhouse_spool_bytes, clickhouse_queue_bytes and clickhouse_healthy (with Netom’s usual metric naming/labels). Alert on errors, queue/spool growth, unsupported routes and target termination; a healthy HTTP endpoint alone does not establish complete history coverage.

Tests

cargo test --lib targets::clickhouse
NETOM_CLICKHOUSE_TEST_ENDPOINT=http://127.0.0.1:8123 \
  cargo test --lib targets::clickhouse::transport -- --ignored

The latter creates a uniquely named table in an existing netom_test database, tests binary fields and same-token retries, then drops only that test table on success. Failure leaves its table available for inspection.

The installed-service driver is python3 scripts/e2e-clickhouse.py --routes 1000. It requires the example configuration and netom_test.events. It supports --feed-only (prints a run token) and --verify TOKEN for outage/restart tests.