Research, Data and Risk Platforms · Technology
4A Tick Store on Open Formats
At 16:05 the day’s capture files close. By 16:40 they must be compacted, sorted and queryable, because the overnight research starts at 17:00, and a researcher who asks for the quote prevailing before each of this week’s trades must not notice which days come from the files written during the session and which from the compacted history. A week in the life of such a store is this chapter: five simulated days go through capture, an intraday store written as the session runs, a compaction at the close, a historical store of partitioned columnar files, and one query layer over both. On the fourth day the quotes gain a field, and the store has to carry on. Nothing here needs a proprietary database: the store is built from the open formats of chapter 3, an embedded query engine and a dataframe library, with a memory-mapped flat file for the hottest data, read the same way from Python, C++ and Rust (firm.tickstore).
4.1 Live capture: append-only files
Definition 4.1 (Tick store, append-only file)
A tick store is the system that keeps a firm’s tick data and answers queries on it, from the current session to the oldest year retained. An append-only file is a file whose writer only adds bytes at its end and never changes what it has written, so that a reader can use every complete record already there while the writer continues.
During the session the store writes the way data arrives: in time order, as it comes, with nothing sorted, compressed or indexed. The events of chapter 2 — the receive time followed by the feed handler’s 48-byte event — go to a flat file of fixed-width records; the quotes and trades derived from them (the top of book after each change, from Book 13’s book builder, and each execution with its price) go to Arrow IPC streams, the in-memory columnar format of chapter 3 written as a sequence of record batches.
Definition 4.2 (Flat file format, schema version)
A flat file format stores records of one fixed width one after another, after a header that states their layout, so that record is at a computable offset and nothing needs to be parsed to reach it. The header’s schema version identifies the layout; a reader refuses a version it does not know.
The store’s flat file (Figure 4.1) starts with a magic number, the version, the record width and the layout as JSON, padded to 64 bytes so that every record that follows is aligned the same way; version 1 records are 56 bytes, version 2 appends a venue, flags and padding for 64. Listing 4.1 is the writer and the reader.
class FlatWriter:
"""Append-only fixed-width records behind a header padded to 64 bytes."""
def __init__(self, path, version: int = 2):
self.dtype = SCHEMAS[version]
schema = json.dumps(self.dtype.descr).encode()
head = HEAD.pack(MAGIC, version, self.dtype.itemsize, len(schema)) + schema
self.f = open(path, "wb")
self.f.write(head + b"\0" * (-len(head) % ALIGN))
self.n = 0
def append(self, rec: np.ndarray) -> None:
self.f.write(np.ascontiguousarray(rec, dtype=self.dtype).tobytes())
self.n += len(rec)
def close(self) -> None:
self.f.close()
def flat_open(path):
"""(version, records) with the records memory-mapped read-only: nothing is read until
it is touched."""
with open(path, "rb") as f:
magic, version, size, slen = HEAD.unpack(f.read(HEAD.size))
if magic != MAGIC or SCHEMAS[version].itemsize != size:
raise ValueError(f"{path}: not a tick-store flat file of a known version")
start = HEAD.size + slen
start += -start % ALIGN
return version, np.memmap(path, dtype=SCHEMAS[version], mode="r", offset=start)
Remark 4.3 (Why two intraday forms)
The flat file is the fastest thing to write and to scan, and the layout the trading path already uses; the IPC stream carries a schema, nullable and string columns, and is read into any Arrow-speaking tool without conversion. A store needs both because its intraday readers differ: a C++ replay tool wants the flat records, a researcher’s notebook wants a table. Each is written once; nothing is converted during the session.
4.2 End-of-day compaction
Definition 4.4 (End-of-day compaction, sort key)
End-of-day compaction rewrites a day’s intraday files into the historical layout: partitioned, sorted, encoded and compressed for reading. The sort key of a file is the list of columns its rows are ordered by; the zone maps of chapter 3 prune well on its leading columns.
The compaction of firm.tickstore (Listing 4.2) sorts each table by symbol and time and writes one Parquet file per partition, under <table>/date=YYYY-MM-DD/symbol=S/: the directory names carry the partition values, a convention the query engines read as columns. It is Method 3.8 of chapter 3 made concrete: partition by date and symbol, sort by time inside.
def compact(tables: dict, root, date: str, row_group_size: int = 65_536) -> dict:
out = {}
for name, t in tables.items():
t = t.sort_by([("symbol", "ascending"), ("ts", "ascending")])
files, size = 0, 0
for sym in sorted(set(t["symbol"].to_pylist())):
part = t.filter(pa.compute.equal(t["symbol"], sym)).drop_columns(["symbol"])
d = pathlib.Path(root) / name / f"date={date}" / f"symbol={sym}"
d.mkdir(parents=True, exist_ok=True)
pq.write_table(part, d / "part.parquet", row_group_size=row_group_size,
compression="zstd")
files += 1
size += (d / "part.parquet").stat().st_size
out[name] = {"rows": t.num_rows, "files": files, "bytes": size}
return out
Figure 4.2 counts the bytes of the simulated week in each form. The busiest day, Monday, has 258 535 events; its intraday files take 17.5 MB (the flat events and the two IPC streams), its compacted partitions 6.15 MB, 2.85 times less, with the events at 20.0 bytes each in their partitions. Compaction of that day took 0.12 s on the laptop: about 2.2 million events a second on one core, sorting, partitioning, encoding and compressing included.
fig_tickstore.py.Method 4.5 (Safe compaction)
(1) Write the day’s partitions to new files in a staging directory, never over the intraday files. (2) Verify them against the intraday data: the same row count per table and symbol, the same range of sequence numbers with the same gaps, the same sums of quantities. (3) Make them visible in one atomic step — a rename of the staging directory, or a new version of a manifest that lists the day’s files. (4) Only then delete the intraday files, or keep them for the retention of the raw capture (chapter 2).
A reader that lists a directory while the compactor writes into it sees half a day; a reader of a manifest sees either the old day or the new one. The verification step is cheap because the intraday files are still there, and it is the last moment at which a lost partition can be rebuilt without going back to the raw capture; chapter 7 turns such checks into the firm’s data-quality rules.
As of September 2026 — The proprietary design this one mirrors
The intraday/historical split is the design of the kdb+tick architecture of the proprietary kdb+ database, long common in banks and trading firms: in its documentation (consulted September 2026) a tickerplant writes incoming records to a log and pushes them to a real-time database, which holds the current day in memory and writes it to the historical database at the end of day; the historical database gives access to all earlier days. This book teaches the open-format version of the same design.
4.3 The intraday and the historical store
Definition 4.6 (Intraday store, historical store)
The intraday store holds the current session’s data in the forms written during it, optimised for appending and for recent reads; the historical store holds every compacted day, optimised for reading ranges across days and symbols.
The split is invisible to a reader only if one query layer covers both. Store opens an embedded DuckDB and creates one view per table: the historical Parquet partitions, read with their directory names as columns, and the current day’s IPC tables, registered in memory, joined by a union that matches columns by name (Listing 4.3). A query on quotes then covers Monday to Friday, whichever store holds each day.
class Store:
"""DuckDB over the historical Parquet partitions and the day's intraday tables."""
def __init__(self, root, intraday: dict | None = None, date: str | None = None):
self.root = pathlib.Path(root)
self.con = duckdb.connect()
for name in ("quotes", "trades"):
hist = self.root / name
parts = []
if hist.exists() and any(hist.glob("date=*/symbol=*/*.parquet")):
files = f"{hist}/date=*/symbol=*/*.parquet"
parts.append(f"SELECT * FROM read_parquet('{files}', "
"hive_partitioning = true, union_by_name = true)")
if intraday and name in intraday:
t = intraday[name]
self.con.register(f"{name}_intraday", t)
parts.append(f"SELECT *, DATE '{date}' AS date FROM {name}_intraday")
if parts:
union = " UNION ALL BY NAME ".join(parts)
self.con.execute(f"CREATE VIEW {name} AS {union}")
def sql(self, q: str) -> pa.Table:
return self.con.execute(q).to_arrow_table()
Example 4.7 (One window, two stores)
The count of SIM2’s quote changes from 09:32 to 09:35 on each day of the week comes back as 12 291, 996, 1 388, 1 197 and 4 473: four days from the Parquet partitions, the fifth from the intraday IPC table, in one SQL statement that names neither. The query took 18 ms on the laptop.
4.4 As-of joins at scale
The as-of join of One Quant Book 7, chapter 3 — each row of one table with the last row of another known at its time — is the tick store’s most frequent query: each trade with the quote that prevailed when it happened, for effective spreads, trade signs and mark-outs. The store computes it three ways, which must agree row for row: DuckDB’s ASOF JOIN, Polars’ join_asof, and Book 7’s firm.pit.asof_join applied to each day and symbol.
ASOF_SQL = """
SELECT t.date, t.symbol, t.seq, t.ts, t.price, t.qty, q.bid, q.ask
FROM trades t ASOF LEFT JOIN quotes q
ON t.date = q.date AND t.symbol = q.symbol AND t.{key} {op} q.{key}
ORDER BY t.date, t.symbol, t.seq"""
COLS = ["date", "symbol", "seq", "ts", "price", "qty", "bid", "ask"]
def asof_duckdb(store: Store, key: str = "seq") -> pa.Table:
"""The quote prevailing before each trade: the last quote of an earlier sequence number
(key='seq'), or, to show the tie problem, the last quote at or before the trade's
timestamp (key='ts')."""
return store.sql(ASOF_SQL.format(key=key, op=">" if key == "seq" else ">="))
seq), or at or before its timestamp (key ts). code/firm/tickstore/firm_tickstore.pyThe first version of the chapter joined on the timestamp, and the three engines disagreed. The reason is in the data: an execution and the change of quote it causes carry the same exchange timestamp, so “the last quote at or before the trade’s time” can be the quote the trade itself produced, and which of two quotes with equal timestamps an engine returns is not specified. The quote that prevailed before the trade is the last one of an earlier sequence number, and the feed’s sequence number totally orders events.
Proposition 4.8 (Join on the sequence number)
If every quote and trade row carries the sequence number of the event that produced it, the strict as-of join on sequence number returns, for each trade, the top of book immediately before the event that executed it, and the result does not depend on the join algorithm.
Proof. Sequence numbers are distinct across events and increase in the order the book processed them. The quote rows of a symbol with sequence numbers below the trade’s are the states of its book before the execution; the last of them is the state just before it. Distinct keys leave no tie for an algorithm to break. ∎
On the week’s 17 281 trades, the three engines agree on every row with the sequence-number join. The timestamp join returns a different quote for 223 of them, 1.3%, each time the quote the execution itself had just produced: exactly the trades whose effective spread a researcher would measure as zero or negative. On this data DuckDB’s join took 16 ms, Polars’ 45 ms, and the Python reference 0.17 s (Figure 4.4).
bench_tickstore.py.4.5 Flat files and memory mapping
Definition 4.9 (Memory-mapped file)
A memory-mapped file is a file mapped into a process’s virtual address space, so that its bytes are read as memory: the operating system loads each page from disk (or from its cache) the first time it is touched, and processes that map the same file share one copy of its pages.
A fixed-width file and a memory map fit together: record is at a known address, nothing is parsed, and only the pages a query touches are read. Book 12’s memory-mapped datasets (chapter 23) use the same mechanism for training data. The store’s reader in Python is numpy.memmap; the C++20 reader (Listing 4.5) calls mmap itself, and the Rust twin declares mmap and munmap from the C library, since the series’ Rust uses no external crates. The three compute the same per-instrument summary — records, quantity executed, last sequence number — on the same fixtures of both versions. Scanning one column of Monday’s events took 0.8 ms from the mapped flat file and 2.1 ms from its Parquet partitions: the flat file is more than twice as fast to scan and 2.8 times larger to keep.
class FlatFile {
public:
explicit FlatFile(const std::string& path) {
fd_ = ::open(path.c_str(), O_RDONLY);
if (fd_ < 0) throw std::runtime_error("cannot open " + path);
struct stat st {};
::fstat(fd_, &st);
size_ = static_cast<std::size_t>(st.st_size);
void* m = ::mmap(nullptr, size_, PROT_READ, MAP_PRIVATE, fd_, 0);
if (m == MAP_FAILED) throw std::runtime_error("mmap failed");
base_ = static_cast<const std::uint8_t*>(m);
if (size_ < 12 || std::memcmp(base_, "OQTS", 4) != 0)
throw std::runtime_error("not a flat file");
version_ = load<std::uint16_t>(base_ + 4);
record_ = load<std::uint16_t>(base_ + 6);
std::size_t start = 12 + load<std::uint32_t>(base_ + 8);
start_ = (start + 63) / 64 * 64;
bool known = (version_ == 1 && record_ == 56) || (version_ == 2 && record_ == 64);
if (!known) throw std::runtime_error("unknown version");
n_ = (size_ - start_) / record_;
}
FlatFile(const FlatFile&) = delete;
FlatFile& operator=(const FlatFile&) = delete;
~FlatFile() { ::munmap(const_cast<std::uint8_t*>(base_), size_); ::close(fd_); }
std::size_t size() const { return n_; }
int version() const { return version_; }
Event operator[](std::size_t i) const { // pages are read from disk when first touched
const std::uint8_t* p = base_ + start_ + i * record_;
using U64 = std::uint64_t;
return {load<U64>(p), load<U64>(p + 12), load<U64>(p + 20), load<U64>(p + 28),
load<U64>(p + 36), load<U64>(p + 48), load<std::uint32_t>(p + 44),
load<std::uint16_t>(p + 10), p[8], p[9]};
}
4.6 Schema evolution
On Thursday the venue added a field to its quotes, and the store’s quotes carry it from that day on. Monday to Wednesday have no such column.
Definition 4.10 (Schema evolution)
Schema evolution is the change of a dataset’s layout over time under rules that let every reader read every version: here, fields may be added at the end with a default, and are never removed, renamed, retyped or reordered.
Under that rule a reader of the new version reads an old file by filling the new fields with their defaults, and a reader of the old version reads a new file by ignoring the fields it does not know; the flat file does both by its version number (upgrade), the query layer by matching columns by name (a missing column reads as null). The same week’s window query counts the venue on none of the first three days and on every quote of the last two. The registry’s check (check_evolution) refuses the changes that would break old readers: a field retyped from 32 to 64 bits, a field dropped, two fields swapped, a field inserted before existing ones.
Method 4.11 (Evolving a tick store’s schema)
(1) Add the field at the end, with a default that means “not known”. (2) Increase the version in the header and register the new layout next to the old one. (3) Write every reader to accept every registered version. (4) Never rewrite history to the new version unless the old readers are retired; if a correction is needed, write the corrected days as new versions (chapter 7).
4.7 Tutorial: a week in the store
Goal. Run five simulated days through capture, the intraday store, compaction and the query layer, with a schema change on the fourth day, and check the as-of join three ways. End state: Figure 4.2, the window of Example 4.7, and three identical joins of 17 281 trades.
- Build the week:
pl_tickstore.build(root)captures each day (capture_day: events to a flat file, quotes and trades to IPC streams, one flush every 20 000 events) and compacts the first four (Listing 4.2). - Query:
window(root)counts SIM2’s quotes in the same three minutes of each day. - Join:
asof_all(root)runs DuckDB, Polars and the reference; then rerun DuckDB with the timestamp as the key and count the trades whose quote changes. - Map: build and run
firm_tickstore_test.cppandcargo testincode/firm/tickstore/ruston the fixtures; compare withflat_summary. - Measure:
bench_tickstore.pytimes the compaction and the queries.
What to change next. Add a third schema version with a condition code and read the whole week; make the compaction write two sort orders (symbol then time, and time then symbol) and compare a cross-sectional query on each.
4.8 Build: the tick store
Purpose. The firm’s tick store: every later chapter of this book that reads market data reads it here, the backtest engine of chapter 11 through its data-access layer, the quality rules of chapter 7 on its partitions.
Interface. EVENT_V1, EVENT_V2, SCHEMAS; FlatWriter(path, version), flat_open(path) -> (version, memmap), upgrade; IpcWriter, ipc_read; quotes_trades(events, symbols); compact(tables, root, date, row_group_size); Store(root, intraday, date).sql(q); asof_duckdb, asof_polars, asof_reference; check_evolution. C++20 firm::tickstore::FlatFile, summarise; Rust FlatFile::open, get, summarise.
Rules. Intraday files are append-only; the header states the version and a reader refuses an unknown one; historical files are written once per day and partition; every quote and trade carries its event’s sequence number; the as-of join is strict on the sequence number; schemas only grow at the end.
Acceptance tests. code/firm/tickstore/tests/, cpp/firm_tickstore_test.cpp, rust/: flat files of both versions written, mapped and refused when foreign; the Python reader equal to the fixture the twins check; quotes and trades from a hand-made book; an IPC round trip; the query layer over two historical days of different schemas and the current day; three identical joins and the timestamp join’s tie; the evolution rules.
Stretch. A second sort order written by the compaction; a sorted merge of intraday and historical rows without a union; compaction of a day while the next day’s capture runs.
Sources and further reading
- KX, kdb+tick documentation (tickerplant, real-time and historical databases).
- DuckDB, AsOf Join (documentation); Polars,
DataFrame.join_asof(API reference). - Apache Arrow, IPC streaming format (specification);
mmap(2), Linux manual page.
4.9 Exercises
Exercise 4.1 ★
Monday’s flat file is 14 478 152 bytes for 258 535 records of version 1. How long is its header, and how long would the file be in version 2?
Solution
Solution of Exercise 4.1.
bytes of header. The version-2 header of the week is 256 bytes (its layout, as JSON, is longer), so the file would be bytes.
Exercise 4.2 ★
Why may a reader use an append-only file while the writer is still writing it, and what must it do with a partial last record?
Solution
Solution of Exercise 4.2.
Bytes already written never change, and records have a fixed width, so the first records are complete and final. The reader ignores the partial record at the tail (the writer is in the middle of it) and reads it on its next pass.
Exercise 4.3 ★
Monday’s intraday files take 17.5 MB and its compacted partitions 6.15 MB. What is the ratio, and where does the saving come from?
Solution
Solution of Exercise 4.3.
. The partitions are sorted by symbol and time and stored as columns, so each column is encoded (dictionaries, deltas) and compressed; the flat records keep every field at full width, and the IPC streams are uncompressed.
Exercise 4.4 ★★
A researcher computes effective spreads with an as-of join on the timestamp. On the week’s data, how many trades get the wrong quote, and in which direction are their spreads biased?
Solution
Solution of Exercise 4.4.
223 of the 17 281 trades, 1.3%: for each, the join returns the quote the execution produced, with the executed level removed. The mid has already moved in the trade’s direction, so the trade looks closer to the mid than it was: the measured effective spread is biased toward zero, and a trade sign inferred from that quote can flip and make the spread negative.
Exercise 4.5 ★★
At the measured compaction rate, how many events can the store compact between 16:05 and 16:40 on one core, and how does that compare with the options feed’s planned 311 billion messages a day of chapter 2?
Solution
Solution of Exercise 4.5.
About 2.2 million events a second for 35 minutes, 2 100 seconds: about 4.7 billion events on one core. The options feed’s planned 311 billion messages a day is over sixty times more: the compaction must run in parallel by partition on over sixty cores, or incrementally during the day (hour by hour), or both.
Exercise 4.6 ★★
A new field is inserted in the middle of the record instead of appended. What does an old reader see, and what does check_evolution report?
Solution
Solution of Exercise 4.6.
If the version number is not changed, an old reader reads the new field’s bytes as the fields that follow it: silently wrong values, the worst failure. If it is changed and the width grows, an old reader refuses the file. check_evolution reports the new field as inserted before existing fields.
Exercise 4.7 ★★★
Coding. With flat_summary and the C++ reader, check the current day’s flat file: how many records does each instrument have, and what quantity was executed on instrument 2?
Solution
Solution of Exercise 4.7.
Instrument 1: 27 157 records; instrument 2: 102 144 records, 367 600 shares executed; instrument 0 (system messages): 4 records. The C++ and Rust readers give the same numbers on the fixtures, which are written by the same Python code.
Exercise 4.8 ★★★
Find the flaw. “To save space we compact the day in place: the compactor reads the intraday files, writes the Parquet partitions over them, and a query during compaction reads whatever is there.”
Solution
Solution of Exercise 4.8.
A crash during the compaction leaves the day neither in its intraday form nor complete in its historical form, and a query during it reads a mixture. Compact to new files, verify them (row counts, sequence ranges), then switch readers to them atomically (a rename of the partition directory or a manifest), and delete the intraday files only after that.
4.10 Problem: The 16:40 Deadline
Problem 4.1
Weekend problem — a week in the tick store
The chapter’s simulated week, its store and the measurements of bench_tickstore.py.
Part I — Intraday.
- How many events, quotes and trades does the week hold?
- What is in a flat file’s header, and why is it padded to 64 bytes?
- Why does the intraday store write two forms, and which readers use which?
- How many bytes is a record in version 1 and in version 2?
- Why is nothing sorted or compressed during the session?
Part II — Compaction.
- What sort key and partition key does the compaction use, and why those?
- How many bytes does Monday take before and after compaction?
- How long did Monday’s compaction take on the laptop, and at what rate?
- How many events could the 35 minutes from 16:05 to 16:40 absorb at that rate on one core?
- What would you change to compact a full options feed in the window?
Part III — Queries.
- How does one SQL statement read both stores?
- How many quote changes does SIM2 have from 09:32 to 09:35 each day?
- Why must the as-of join use the sequence number rather than the timestamp?
- How many trades change quote between the two joins, and which engines agree?
- How long did the three joins take?
Part IV — The verdict.
- State the named result: the compaction rate and the size reduction of the busiest day, the query latencies, and the largest day the window absorbs.
- How do old and new quotes coexist after Thursday’s schema change?
- What does the memory-mapped flat file buy over Parquet for the current day, and what does it cost?
- Which part of this design comes from the proprietary tick databases, and which from the open formats?
- In one sentence: what makes a tick store’s two halves look like one?
Solution
Solution of Problem 4.1.
- 564 162 events, 96 206 quotes and 17 281 trades.
- The magic number, the version, the record width and the layout as JSON; padded to 64 bytes so that every record starts at the same alignment in every file.
- The flat events are the layout of the trading path, for replay and C++ or Rust tools; the IPC tables carry a schema and strings, for dataframes and SQL.
- 56 and 64 bytes.
- The session must write at the rate data arrives; sorting needs the whole day, and compression costs time the capture does not have.
- Partitioned by date and symbol, sorted by time inside: queries name a date range and a symbol and filter a time window.
- 17.5 MB intraday, 6.15 MB compacted.
- 0.12 s for 258 535 events, about 2.2 million events a second on one core.
- About 4.7 billion.
- Compact partitions in parallel, compact incrementally during the session, and keep the intraday form cheap to convert.
- The query layer defines one view per table as the union, by column name, of the historical partitions and the current day’s table.
- 12 291, 996, 1 388, 1 197 and 4 473.
- An execution and the quote change it causes share a timestamp; the sequence number orders them.
- 223 trades; DuckDB, Polars and the reference agree on every row with the sequence number.
- 16 ms, 45 ms and 0.17 s.
- Named result. Monday’s 258 535 events compact in 0.12 s (about 2.2 million events a second on one core) from 17.5 MB to 6.15 MB; a five-day window query takes 18 ms and the week’s as-of join 16 ms in DuckDB; at that rate the 35-minute window absorbs about 4.7 billion events, about one sixty-sixth of the options feed’s planned day.
- The old days have no venue column and read it as null; the flat files carry their version and are upgraded on read with the field at its default.
- A scan four times faster (1.2 against 4.8 ms) with no decoding, at 2.8 times the size.
- The intraday/historical split and the end-of-day write to history come from the tick databases; the formats, the query engine and the dataframes are open.
- One query layer that matches columns by name over the current day’s tables and the historical partitions.
4.11 Interview questions
Interview question 4.1 ★ developer, researcher
Why do tick stores separate the current day from history?
Solution
Solution of Interview question 4.1.
They want opposite layouts: the current day must be written as it arrives (appended, in time order, unsorted), history must be read in ranges across days and symbols (partitioned, sorted, compressed). Compaction converts one into the other once, at the close, and a query layer hides the split.
What the interviewer is looking for: Write-optimised against read-optimised, and the compaction between them.
Interview question 4.2 ★★ developer, researcher
You join trades to quotes as of the trade time and some effective spreads come out negative. What is wrong?
Solution
Solution of Interview question 4.2.
The as-of join on the timestamp can return the quote the trade itself produced, since both carry the same exchange time: the mid has already moved and the spread looks smaller or negative. Join strictly on the feed’s sequence number (the last quote of an earlier sequence number), or on a receive-order key.
What the interviewer is looking for: Ties at equal timestamps; strict join on a total order.
Interview question 4.3 ★★ developer
What does memory-mapping a file give you, and when is it a bad idea?
Solution
Solution of Interview question 4.3.
Reads without system calls or copies: pages are loaded on first touch and shared between processes, and fixed-width records are addressed directly. It is a bad idea for files larger than memory with random access (page faults dominate), for files that can be truncated under the reader, and where latency must be bounded (a page fault on the hot path).
What the interviewer is looking for: Page faults, sharing, and the failure cases.
Interview question 4.4 ★★ developer
A venue adds a field to its feed. How do you change the store without breaking five years of readers?
Solution
Solution of Interview question 4.4.
Add the field at the end with a default, raise the version in the headers and the registry, make every reader accept every version, and leave old files as they are: old readers ignore the field, new readers see the default for old days.
What the interviewer is looking for: Add-only evolution with versions; no rewrite of history.
Interview question 4.5 ★★ developer
How would you partition and sort a year of quotes for 8 000 symbols, and why?
Solution
Solution of Interview question 4.5.
Partition by date (every query has a date range) and, if files stay large enough, by symbol or a hash bucket of symbols; sort by symbol then time inside; row groups sized by measurement. 8 000 symbols a day would make tiny files per symbol for the illiquid ones, so bucket them.
What the interviewer is looking for: Date partitions, symbol order, and the small-files problem.
Interview question 4.6 ★★★ developer
Design a tick store on open formats for a firm without a proprietary database: capture, intraday, compaction, historical layout, query layer, and what you would measure first.
Solution
Solution of Interview question 4.6.
Capture to append-only flat files and Arrow IPC during the session; compaction at the close to Parquet partitioned by date and symbol, sorted by time, written to new files and switched atomically; DuckDB or Polars over both through views matched by name; versioned schemas. Measure first the bytes per event in each form, the compaction rate against the close-to-research window, and the latency of the main queries (as-of joins, windows).
What the interviewer is looking for: The whole cycle with an atomic switch, and the measurements that size it.