6.4 Cross-Shard Fan-Out

Event queries execute against every database in the event store directory — active shards and historical ones alike (§3.3). Log and metric queries touch one database each and need none of this.

Shards carry no meaning for the query path (§2.3). A shard holds whatever CPUs routed to it during whatever lifetimes wrote it, so there is no shard a query can skip on the basis of its contents, and a predicate on cpu_id scans all of them.

6.4.1 Merging #

How results combine depends on the query.

Non-aggregating queries. Each shard produces rows sorted by the effective sort key, tiebreakers included (§6.2), and the coordinator performs an N-way merge of the sorted streams.

With TAKE present, each shard returns at most SKIP + TAKE rows — or TAKE rows when there is no SKIP — and the coordinator applies SKIP and TAKE after merging. The total read is therefore at most (SKIP + TAKE) × shard_count, which is the price of not knowing in advance which shard holds the winning rows.

With TAKE absent, each shard streams every matching row until the query completes or times out.

Aggregating queries. Each shard computes a partial aggregate and the coordinator combines them:

QueryShard returnsCoordinator does
COUNTits local countsums
COUNT BY, TOP N BY, GROUP … COUNTper-group countssums per group key, sorts by count descending, applies TAKE
GROUP … SUMper-group sumssums per group
GROUP … AVGper-group sum and countcomputes the average from the combined pair
GROUP … MIN / MAXper-group min or maxtakes the min or max
DISTINCTlocal distinct valuesunions

AVG is the one that cannot be composed from its own output. Averaging per-shard averages weights each shard equally regardless of how many rows it held, so a shard is asked for the sum and the count and the coordinator divides once — the same reasoning that makes rollup composition carry sample_count (§5.6).

Pushing aggregation down bounds the coordinator's memory to the group key cardinality times the shard count, rather than to the total row count.

6.4.2 The unbounded case #

A non-aggregating query without TAKE has no implicit row limit. EVENTS SINCE 7d ago may match millions of rows, all of which pass through the merge.

The query timeout is the only backstop (§6.5). Streaming merged results to the client incrementally, rather than materialising the whole set before sending, is what keeps the memory cost proportional to the merge frontier instead of to the result.

6.4.3 Descriptors #

An event query opens a read-only connection per database, and each SQLite connection holds one or two descriptors for the database and its write-ahead log. With many historical shards this adds up quickly across concurrent queries.

Active shard writer connections stay open for the process lifetime and are not negotiable. Historical shard read connections are the pool worth bounding — opened when a query touches them, closed after a period of inactivity (§C).

Edit this page