Investigation: The tick ingest pipeline design record

Table of Contents

A point-in-time record, written while the story that commissioned it was open, of an investigation into how the target tick ingest pipeline was reached: why the earlier path could not meet the requirements, the write ceiling measured, how tick systems are built in practice, the options set aside, the risks and the open questions.

It is a snapshot, not a description of the system. Whatever it concludes that the code must keep doing belongs on the page that owns the subject; cite that page from code, recipes and models, not this one.

1. Context

1.1. Why the present path cannot meet them

The ingest loop handles each tick on one thread, in arrival order. For each route of the tick, it reads the series, inserts two observations, and publishes to JetStream. Each insert is a commit. The publish waits for an acknowledgement. The rule that the republish follows the stored row ties the live view to the speed of the database.

The queue sits inside the NATS client. It has no age limit, and it serves the oldest tick first. When the loop falls behind, it spends its time on old ticks while newer ticks wait. This is the behaviour that requirement 1 forbids.

Measurements on one environment (release build, one server shared by five environments, a burst of 4,000 ticks) show the limit.

Configuration Rows written each second
As built: synchronous commit on, one NOTIFY for each row 143
Synchronous commit off for the marketdata role 166
Synchronous commit off, and the NOTIFY function empty 210
Step 1 built: batches, no notification, core NATS about 2,260

Three facts come from the samples.

  • Exactly one database session of the marketdata service is active. The work is serial.
  • With the commit sync removed, 41 percent of the samples of that session wait on a database-wide lock, 34 percent run on the CPU, and 24 percent wait for the service. The lock is the one that a transaction takes at commit after it calls NOTIFY. PostgreSQL takes it so that notifications enter the queue in commit order. The row trigger ores_marketdata_market_observations_notify_trg calls pg_notify for every row.
  • The blockers of that lock were the marketdata sessions of two other environments on the same server. The lock is shared across databases, so ingests that share one server slow each other.

A commit sync is also slow on this disk. One fdatasync takes about 4.3 ms.

The write path has more costs for each row than the commit. Each row passes a before-insert trigger that runs an UPDATE on the same table and calls ores_iam_validate_tenant_fn. The table has seven indexes, and one of them is a partial unique index. Each statement runs through a tenant-aware connection that sets app.current_tenant_id when it takes the connection.

Two JetStream streams also store every tick. The stream synthetic_ticks covers the producer subjects, and the stream marketdata_ticks covers the republish subjects. Both keep their messages in files for 7 days, and both have no consumer. A producer waits for an acknowledgement of each tick, and the ingest waits for one more for each route. The streams give nothing that a client reads. In one environment the second stream holds 1,812 subjects after two days.

The demand is small today. One environment receives 13 ticks each second. Each tick reaches 4.6 routes on average, and each route stores two rows. That is about 120 rows each second against a ceiling of about 150. At 30,000 ticks each second the same fan-out is about 280,000 rows each second.

1.2. What the first consumer needs: Hedgr

Hedgr is the new desktop client. Its plan, docs/plans/market-eventing.md in the Hedgr repository, shows how a real consumer reads the pipeline.

  • Live values come from the party tick stream. Hedgr subscribes once for each session to marketdata.v1.ops.market_tick.<tenant>.<party>.>. This is the subject that the ingest loop publishes to after it stores a row. The tick stream is the only subscription that Hedgr makes in its own code. Hedgr has no subscription to any observation change event. That confirms that the database notification serves no client here.
  • Hedgr filters on the client. The tick sink of Hedgr says that the party subscription delivers every tick of the party, and that it keeps the ticks of the watched series and drops the rest. Each tick costs a decode, two calls of the URI codec, a number parse, a lock and a map lookup, on the one NATS delivery thread. The section on the live subject makes the broker do the selection.
  • The live tick has no coordinate. The backfill of Hedgr gives each point its term. The live tick carries a series URI, a time and a value, and the aggregator takes no term. A series with coordinates, such as the pillars of a curve, would mix its pillars in one live bucket stream. This was read in the code and not run against a live curve. FX spot has no coordinate and is not affected.
  • Hedgr waits for the store without knowing it. The subject carries a tick only after the ingest has stored it. When the ingest fell behind, the chart showed ticks that were 70 minutes old, and nothing in Hedgr was at fault. The live path of this proposal removes that coupling.
  • Hedgr measures freshness itself. It judges the age of a tick from the observation_time of the tick, and it draws that age beside the verdict. This is the right measure. It is also the acceptance measure for the pipeline: the age of a tick as the client sees it, at the 99th percentile.
  • Hedgr already conflates. It buckets ticks into 1 second, 1 minute and 1 hour levels on the NATS thread, and it passes closed buckets to the frame loop through a fixed ring. A chart needs at most one point for each second. The class interval of 100 milliseconds therefore loses nothing that a chart shows. It cuts the fan-out from tens of thousands of ticks each second to at most ten for each datum. A consumer that wants less conflates for itself, as Hedgr does.
  • The subject is scoped to a party. The live publish must still go to one subject for each route. Its cost is small compared with a commit, because core NATS carries it without an acknowledgement. The cost grows with the number of parties that watch a source.
  • Hedgr seeds a chart with one history call. On open, on reconnect and on a manual refresh it asks for a span of time and merges the reply with its live ticks. See A chart reads a series with one history call and one live subscription.
  • One difference needs a decision. The ring of Hedgr drops the newest bucket when it is full. Its source comment gives the reason: a tick is worth less than the progress of the dispatch thread, and the chart shows the points it did receive. The freshness rule says to overwrite the oldest. The two rules differ only when the frame loop stalls by more than 4,096 buckets, but they should agree.
  • The reconnect gap. Hedgr loses the ticks between a drop and a reconnect, and it fills the gap with a history call from its newest tick. The recent window serves the recent part of that call from memory.

1.3. The live subject before it was tokenised

The republish subject ended in one token: the whole canonical URI of the datum, percent-encoded. A wildcard could then select a whole party and nothing smaller. The Hedgr client subscribed to the whole party and discarded the ticks of series that it did not watch. Each session decoded every tick of its party. At tens of thousands of ticks each second, that costs a delivery thread for each session, and it costs the broker the same traffic for each session. It broke requirement 8.

The change touched every consumer of the stream. Hedgr was the only client, and the Qt client no longer existed, so the cost of the change was at its lowest.

1.4. Taken from the target note

  • The second row for each tick. The ingest stored each tick twice, once at its own time and once under the previous business day, so that a fresh environment could value a report at the previous close. The second row was the newest value under another date, and it doubled the writes. Decision of the owner, 2026-10-21: the ingest stores only the real tick. A close is a question for the reader, which asks for the latest tick at or before the end of the day.

1.5. Taken from the target note

  • Indexes. The table has seven indexes and a trigger that closes the previous current point. Each index costs a write. A measurement with a batch decides which of them the hot path needs.

2. Methodology

2.1. Step 0: the measured write ceiling

The first pass of step 0 times batched inserts into scratch tables of one environment, on one connection. The rows are made inside the database, so the figures leave out the client and the network. They are upper bounds. The script and the raw output are in the evidence folder of the investigation task.

Rows in a transaction Observation table, as built Same table, no trigger Append-only table
1 231 each second 273 226
100 6,024 18,225 22,890
1,000 7,815 28,716 111,028
10,000 8,301 32,751 186,824
Bytes for each row, with indexes 1,199 942 162

Four facts follow.

  • One row for each commit is commit-bound. The three shapes give 226 to 273 rows each second, and that is the 230 syncs each second of the disk.
  • Batching alone does not help the present table. It rises from 231 to 8,301 rows each second and stops there. The trigger and its two lookups of the tenant table for each row set the ceiling. Removing the trigger gives four times more, and the seven indexes then set a new ceiling of about 33,000.
  • The append-only table is 22 times faster than the present one. One index and no trigger give 187,000 rows each second on one connection, at 162 bytes for each row.
  • COPY from a client file loaded 300,000 rows into the table without a trigger in 7.3 seconds, which is 41,000 each second, and into the append-only table in 1.6 seconds, which is 188,000 each second.

The demand of 276,000 rows each second needs 34 connections of the present table, at perfect scaling, and a disk that writes 330 megabytes each second. It needs two connections of the append-only table, and a disk that writes 45 megabytes each second. With one row for each source it needs less than one connection.

These figures have limits. The scratch tables were empty at the start, so the indexes were small and warm. The server was shared with four other environments. Row-level security and the tenant-aware connection were not in the test. The second pass below removes the first and the third limit and states what stays.

2.1.1. The second pass, from the application

The second pass measures from C++ code with libpq, in a hidden test of the marketdata core tests, so it includes the client: the text of each row, the unique identifier of each row, and the round trips. The script and the raw output are in the evidence folder of the task. All figures are rows each second for one connection.

Writer Table Rows in a transaction Rows each second
market_observation_repository::insert (the ingest today) observation, as built, one tenant 100 919
same same 1,000 1,776
same same 5,000 2,195
same same 20,000 2,326
COPY through libpq observation shape, with a copy of its insert trigger, no row-level security 1,000 10,237
same same 10,000 10,568
same same 50,000 11,086
COPY through libpq observation shape, no trigger 1,000 34,619
same same 10,000 46,424
same append-only 1,000 92,839
same append-only 10,000 150,545
same append-only 50,000 185,259

The batch of one tenant matters. A batch of 50,000 rows that the lane splits across tenants makes one transaction for each tenant.

Tenants in a batch of 50,000 Rows in a transaction Append-only table Observation shape
1 50,000 185,259 37,214
10 5,000 114,276 33,975
100 500 63,078 15,758

A transaction of 100 rows gives 12,000 to 19,000 rows each second on the tables without a trigger, a transaction of 10 rows about 2,000, and a transaction of one row about 220, which is the commit rate of the disk. So a transaction should hold at least 1,000 rows. With 100 tenants that means a batch of 100,000 rows, which is a long wait at a low tick rate.

The history costs 6 to 25 percent, from single runs on a shared server. A table that holds 100 million rows in 61 chunks of one day takes 141,154 rows each second at 10,000 rows in a transaction, against 150,545 when it is empty, and 137,992 at 50,000 rows, against 185,259. The observation shape with 5 million rows still takes about 48,000 to 50,000. The server filled the 100 million rows at about 240,000 rows each second, and the rate did not fall as the table grew. A row of the 100 million row table takes 178 bytes with its index, and a row of the observation shape takes 986 bytes.

Four conclusions follow.

  • The writer, the trigger and the table each cost a factor of about five. The repository insert gives 2,326 rows each second. COPY into a copy of the same table with its insert trigger gives 10,568 at 10,000 rows, which is 4.5 times more, so the writer is a factor of about five. The same COPY without the trigger gives 46,424 and 53,794 in two runs, so the trigger is another factor of about five. The append-only table, which has no trigger and one index, gives 185,259, which is 80 times the repository insert. The batch writer should use COPY. The trigger must go as well, or its work must move into the batch. The scratch copy has no row-level security, so the cost of the policy sits inside the repository figure and is not separated. PostgreSQL refuses COPY FROM for a role that the policy applies to. A role with BYPASSRLS can copy, as the section on tenant and party isolation shows.
  • A row for each tenant makes small transactions. At 100 tenants the append-only table falls from 185,000 to 63,000 rows each second, and the observation shape to 16,000. A row for each source, with the tenants reading it through the routes, keeps one large transaction. This turns that open question into a requirement.
  • The append-only table meets the target on two connections, or on one with one row for each source. With today's fan-out the target is 276,000 rows each second, which needs two connections at 141,000. With a row for each source it is about 30,000 rows each second, which one connection holds with room.
  • Compression is not part of the plan. See the volume item above.

What stays untested: the row-level security policy and the tenant-aware connection on the append-only table, a real mix of series and times, and the disk of a machine that is not shared.

2.2. Tenant and party isolation of the tick table

2.2.1. What the second pass found

The second pass tried to write the observation table with COPY as a role that the policy applies to. The server answered "COPY FROM not supported with row-level security". That role was ores_brave_hopper_test_ddl_user, which has no BYPASSRLS. A later test showed that COPY works for a role that has BYPASSRLS, also when that role does not own the table. So the fast writer and the policy do not exclude each other. The rule is by role.

2.2.2. What was measured

All figures are from single runs on a server shared with other environments. The raw output is in the evidence folder of the task that measured them.

Shape Rows in a transaction Rows each second
append-only, no owner columns 1,000 92,040
same 10,000 181,129
same 50,000 180,185
with tenant_id and party_id, FORCE policies, written by a BYPASSRLS role that does not own it 1,000 116,845
same 10,000 155,531
same 50,000 194,094
same, 50,000 rows split over 100 tenants 500 76,952
append-only, 50,000 rows split over 100 tenants 500 76,481

The owner columns show no cost that this test can resolve. The ratio of the owner shape to the append-only shape ranged from 0.86 to 1.27 across the cells, and the two shapes ran as different roles in separate processes. These are single runs, so the table does not show that the cost is zero. It shows that the cost is smaller than the noise. A rerun with the final policy form, in one process, gave 1.38, 1.23 and 1.01 times the append-only rate at 1,000, 10,000 and 50,000 rows, and 1.37 times at 50,000 rows split over 100 tenants. The owner shape was never slower in that run.

The read test held 5 million rows in 30 chunks of one day, with 100 tenants, 1,000 parties and 2,000 series. The reader was subject to both policies. The times are milliseconds.

Policy One series, 2,500 rows, by the index One party, 5,000 rows, a scan One tenant, 50,000 rows, a scan
none 3.4 212 203
the session functions, as the other tables define them 23 to 26 449 to 586 726
inline current_setting 16 258 to 307 446
a scalar subquery for each setting 4.5 509 506

Five findings follow.

  • The session functions are the cost. ores_iam_current_tenant_id_fn and ores_iam_visible_party_ids_fn are plpgsql functions with an EXCEPTION block. The planner calls them for every row that it filters, and each call starts a subtransaction. A read of one series by the index is seven times slower, at about 8 to 10 microseconds for each row. A subquery around the setting makes the planner evaluate it once for each query, and the same read takes 4.5 ms against 3.4 ms without a policy. Every table that uses these functions in a policy has this cost. That is a separate task.
  • The party policy needs a different form. party_id = ANY((select ...)) does not compile, because PostgreSQL reads it as a comparison with a subquery. The form that works is party_id in (select unnest(nullif(current_setting('app.visible_party_ids', true), '')::uuid[])). The first attempt failed to create the policy, and the check that followed read 5,000 rows of a party that was not visible. The test of isolation must run after the policy exists, and it must include a party outside the visible list.
  • A scan pays for the policy on every row. A read of a party or a tenant without an index scans the chunks, and the policy adds 0.3 seconds over 5 million rows. The tick table has an index on the series and the time only. A reader that wants a party or a tenant must list its series first and read by series.
  • The isolation held. A session of another tenant read 0 rows of the series of the first tenant. A session with no settings read 0 rows. A session of the right tenant with only one party visible read 0 rows of another party and 5,000 rows of its own. The writer role was refused SELECT.
  • The cost of the owner columns on disk is not measured. They add 32 bytes to a row of about 178. This is arithmetic from the column sizes. The points of the design carry no owner columns at all.

2.2.3. The options that were set aside

  • A table for each party. The data fits it, because nothing is shared. It costs a chunk for each table each day, so 100 tenants and 61 days make 6,100 chunks. Each table needs its own policies, a new party needs a new table, and a reader that spans parties needs a view over all of them. The policy gives the same isolation for no catalog cost. This option stays as the fallback if one party must be erased on demand or kept apart on disk.
  • Isolation in the service layer only. One forgotten code path would be the only guard, and every other table of the project has a database policy.
  • A view with the tenant filter over a table without a policy. It works, but it is not the pattern of the other tables.
  • A staging table, then an insert from it. It writes every row twice, and the bypass role makes it unnecessary.
  • A multi-row INSERT with the policy. It gives up COPY. It was not measured.
  • One row for each source. A tick belongs to a series, and the series belongs to a party. A blended or vendor series exists only for its party, and a valuation must reproduce the tick that the party observed. A route that only passes a series through inside one tenant could read the source series through the route. That is an optimisation to evaluate later, and it is not part of the base design.

2.2.4. What stays untested

  • The provisioning of the writer role. The project creates its service roles with a script, and none of them has BYPASSRLS except the DDL users. A new role needs a change to that script in every environment.
  • drop_chunks as the retention job on the Apache build.
  • A read of one party or one tenant by its series list, at the size of a real deployment.
  • The cost of the policy in the repository code that reads through the tenant-aware pool, which adds the client path to these figures.

3. Paths taken

3.1. How tick systems are built in practice

Tick processing is an old problem, and the industry has settled on a small number of shapes. This section lists them. The sources are linked where this session read them. Claims that come from a vendor are marked as such.

3.1.1. The tickerplant, the real-time store and the history store

The shape that many trading firms use comes from kdb+. A feed handler captures the raw feed. A tickerplant receives each update, writes it to a log file, and publishes it to subscribers. A real-time database subscribes, holds the current day in memory, and answers intraday queries. At the end of the day it writes its data to a historical database that is partitioned by date. The KX architecture guide and the kdb+tick page describe the parts. The log is the recovery source: a restarted real-time database replays it.

Two details matter here. First, the tickerplant can run in zero-latency mode or publish in batches on a timer. The KX guidance says that a subscriber that only plots graphs needs an update every few seconds, so a chained tickerplant serves slow subscribers. That is conflation by another name. Second, the split between a real-time tier in memory and a history tier on disk is the norm. A chart of the last few minutes reads memory. It does not query the history store.

3.1.2. Conflated feeds

Exchanges and vendors sell the same idea as a product. The CME MDP 3.0 conflated feed sends only the most recent update of each instrument, which cuts the bandwidth and the work of the consumer. See the description of a handler for it. A patent that came up in the same search notes the limit: some data conflates well, such as the current price, and some does not, such as a trade cancellation. This pipeline carries observed values, so it can conflate them. A trade or an event needs a lane that does not conflate.

Chronicle sells a ring buffer in two forms: one that delivers every message in order, and one that conflates to the newest value for a reader that wants only that. It claims under 200 nanoseconds at the 99.9th percentile for the conflating form. That is a vendor figure on one machine, so treat it as indicative. See the Chronicle page.

3.1.3. In-process hand-off

Low-latency trading systems pass messages between threads on a ring buffer. The LMAX Disruptor, Aeron with its ring-buffer adaptors, and Chronicle Queue are the common tools. They exist for nanosecond hand-off. This pipeline needs a millisecond commit, so it takes the pattern and not the tools.

3.1.4. Time-series databases

A database that is built for ticks ingests in batches and makes the commit interval an explicit setting.

  • QuestDB writes through a write-ahead log and takes data over its line protocol. Its out-of-order handling holds rows in memory and commits after a lag. The commit lag guide calls the setting a trade between throughput and the time until data is readable. A forum answer in the same results says the cost sits mostly in the commit and not in the number of rows. The lane cycle makes the same trade, and it sets the lag from the measured lag of the lane.
  • ClickHouse wants inserts of 10,000 to 100,000 rows, about one each second. When clients cannot batch, its asynchronous inserts batch in the server and accept a loss on a crash.
  • TimescaleDB groups rows by chunk and writes each chunk in one transaction. It recommends COPY for bulk loads. See the insert documentation.

All three say one thing: never commit once for each tick.

3.1.5. Streams with a compacted topic

Kafka offers log compaction, which keeps the last record of each key. A common design puts ticks on a Kafka topic and loads a column store from it, for example through the ClickHouse Kafka table engine and a materialized view. It fits a system that must replay. It adds a broker, and a consumer that falls behind reads old records first, which is the behaviour this pipeline avoids.

3.1.6. The last value for a late joiner

A consumer that starts late needs the current value at once. The kdb+ real-time database serves it from memory. A compacted topic serves it as the last record of a key. This proposal serves it from the memory of the lane, as the real-time database does. It needs no extra stream.

3.2. Where the proposal follows practice and where it departs

It follows practice in these points.

  • It has a receive stage, a fan-out to live consumers, and a store writer, which is the tickerplant, the real-time store and the history writer in the kdb+ shape.
  • A lane that conflates for its live consumers is a chained tickerplant.
  • Writes to the store are batched with an explicit commit interval, as in QuestDB, ClickHouse and TimescaleDB.
  • The recent window is the real-time database of a kdb+ system, held in each lane. It answers recent reads from memory, and the history store serves only older data.
  • The store has no change signal for each row, because none of these systems has one.

It departs from practice in two points, and both follow from the requirements.

  • No replay log. A tickerplant logs every tick so that a restart can replay it. This pipeline keeps no log, because a replayed tick is a stale tick. A restart resumes at the newest tick.
  • Age-based shedding. A tickerplant keeps every tick and lets a slow subscriber fall behind. This pipeline drops old ticks and keeps the newest.

3.3. Prior art

  • The LMAX Disruptor (Thompson, Farley and others, 2011). A ring buffer with slots allocated once, sequence numbers, one writer for each ring, and a consumer that takes every available event as a batch. It avoids locks and queue contention. This pipeline takes four ideas from it: the bounded ring, the pre-allocated slots, the single writer, and batching without a timer. It does not take the library, the busy-spin wait strategies or the graph of consumers. The cost here is milliseconds for a commit and not nanoseconds for a hand-off, so a plain mutex and a condition variable are enough for the receive side.
  • The single writer principle (Martin Thompson, "Mechanical Sympathy"). One thread owns each piece of mutable state. The lane follows it: one writer owns the ring and the connection of its lane.
  • Reactive libraries. Reactive Streams libraries name the same drop-the-old idea onBackpressureLatest. A consumer that cannot keep up gets the newest item and not the oldest.

3.4. Options set aside

  • A durable JetStream consumer as the buffer for quotes. It makes the backlog visible, and it loses nothing at a restart. It also replays old ticks in order, and requirement 1 forbids that. It fits data that must not be lost, and the durable tier uses it. It does not fit a perishable tick.
  • Back-pressure that blocks the producer, for quotes. It protects every tick and makes the newest tick wait for the oldest. It breaks requirement 1. The durable tier uses it.
  • A JetStream stream with one message for each subject, for late joiners. It holds the last value. The window in each lane holds it already, and it also holds the recent run that a chart needs. A second copy would need its own upkeep.
  • A GPU. The work waits for commits and locks and does little arithmetic. A GPU has nothing to speed up on this path.
  • A column store for the history now. A column store takes more than a million rows each second. It is a large change, and a batched write to the present store may be enough. Measure first. The decision belongs to steps 0 and 3 below.

4. Conclusions

4.1. The risks, and what would change the design

Risk What would show it What changes if it is true
PostgreSQL cannot sustain the batch rate. The target is about 29,000 rows each second, one for each source tick, and about 116,000 in an active market. Step 0 shows that the present table cannot, at 8,300 each second for each connection, and that an append-only table can, at 187,000. The next pass of step 0, from the application. It writes batches of 5,000 rows into the hypertable with the notification trigger removed, and it times the rate for each batch size. Move the history to a column store sooner. Cut the fan-out first: one row for each source, and no second row for each tick.
One thread decodes every tick. The busy share of the receive thread at 30,000 ticks each second. Run several ingest instances, as the section on scale describes.
One series is much hotter than the others. The busy share of each lane. Split the lane key below the series, and give up whole-series operations for that class.
The volume of rows exceeds what the store holds. The bytes for each row and the rows each day, measured in step 0. Store bars only for the high-rate classes, and keep raw ticks for a short time.
The trigger and the indexes cap the rows each second of one connection. The step 0 table with and without the trigger and with fewer indexes. The append-only tick table of the store section.
The tick streams of JetStream cap the rate of the producers. The rate of a feed with and without the acknowledgement. Core publish, and no stream, for perishable subjects.
The windows use too much memory. The memory of a lane against the number of watched datums. Bound the window by bytes, and keep a bucketed window.
The live subject change breaks a consumer. A search for every subscriber of the republish subject. Hedgr is the only client. Migrate the client in the same wave, and keep no old subject.

5. Recommendations

5.1. Notes to update when this is built

When the pipeline replaces the loop, these notes change: the ingest loop note, which states that a republish follows the stored row; the note on adding an asset class, which gains the rules above; the PostgreSQL architecture note, which states that a time-series table sets the notification knob to false; the notes on the producer subject and the market tick, which state the live subject; and the subscription note. No client of the observation change event exists. Hedgr has none, and the Qt client is decommissioned.

5.2. Delivery in four steps

Each step ends with a measurement. The test tool is the tick injector, which sends a burst of ticks at a chosen rate and times the drain.

  1. Prove the write ceiling. Build a prototype of the batch writer and time it. No product code changes. Find how sqlgen::insert writes a vector: the library splits it into batches of 50,000 rows, and the form of the statement is not yet verified, so the prototype may need COPY from libpq. Acceptance: a table of rows each second for each batch size, with and without the trigger that closes the previous point, with the append-only table, and with the policy of one tenant for each batch. It also states the bytes for each row. The result decides whether the later steps need a column store.
  2. Remove the per-row costs. Step 0 shows that batching the present table stops at 8,300 rows each second, so this step moves ticks to the append-only table. Drop the notification trigger of the observation table, add a series cache and a batch writer, and stop waiting for acknowledgements on the live publish and on the producers. Remove the two tick streams for perishable subjects. Add the lag gauge. Acceptance: the burst drain rate reaches the rate that step 0 measured, and the lag gauge shows the lag of a loaded system. The ten-fold gain is a target and not a result until step 0 confirms it.
  3. Lanes, the ring, the age gate, the overload states and the recent window. Add the history call, which the window and the store answer in one reply, and split the live subject into tokens. Move Hedgr to both: a subscription for each watched series, and a history call. Acceptance: a burst of ten times the sustained rate leaves no backlog after the burst ends. The lag of the live path stays below the age limit at all times. A chart opens from the window in under 100 ms and does not read the store for the span that the window covers.
  4. Scale out, and open the policy table. Several lanes, each with its own connection. The policy table with its default row, the durable tier, the taps that replace the CRM if, and a rollout by source: a setting moves one source at a time to the new pipeline. Tune the indexes and chunks. Decide on a column store for history. Acceptance: 30,000 ticks each second for ten minutes, with the lag of the store below 1 second at the 99th percentile.

5.3. Open questions

  • Does a stream that can carry a tick that the store dropped cause a problem for any consumer? This note accepts it. The stream is the live view, and the table is the record of what the pipeline wrote.
  • What is the age limit for each asset class? Interest rate curves tick slowly, and FX spot ticks fast.
  • Should the CRM bridge read from the conflated live path and not from the store path?
  • Is the second row for each tick worth its cost at scale?
  • Should the ring of Hedgr overwrite its oldest bucket when it is full, to follow the freshness rule?
  • How large is the recent window? The starting bound is 600 ticks or 10 minutes for a watched datum. Hedgr keeps 2 hours of 1 second points for a watched series, so a longer window or a bucketed window may be needed.
  • How does the lane learn which datums a consumer watches? A subscription, a request to open a chart, or a setting for each source are the choices.
  • Which tokens does the live subject need beyond the instrument type and the series? A venue or a currency token would let a client select more finely, and each token adds a level to every subscription.
  • Does any client need to learn that a series changed? If so, the signal belongs on the live tick stream, at most once each second for each series. A database notification is not the way.
  • Which classes need bars and which need a reducer? The answer comes from the first equity and option feeds. The default row serves FX and rates.
  • How does a client learn that a curve published as one consistent set? The tick has no field for it today, so a curve may need a set marker on the tick.
  • Does any series need a lane key below the series? A very large surface in one series may overload one lane. The busy share of each lane answers it.
  • Does the reader of the store need the bitemporal observation table for ticks? No. The readers of snapshots, evolution, slices and the ORE export read the tick table and the observation table side by side, and take the latest value of each datum. They need no view.
  • How long does the store keep raw ticks, and which classes keep bars only? The answer is a policy row for each class.
  • Who provisions the writer role with BYPASSRLS, and does the project accept a role that skips the policy for writes? The section on tenant and party isolation gives the design and the measurements.
  • Should the session functions of the policies change for every table? The section on tenant and party isolation shows a seven-fold cost on an indexed read.
  • The deployment runs TimescaleDB under the Timescale licence, which compresses chunks and schedules the retention and compression policies. See The tick store is bounded.
  • How many lanes does one server need, and how many connections can the database give them?

5.4. Open questions of the history call

  • How does the lane learn that a datum is watched? If the subscription tells it, the window fills from the subscription on, and a tick that is in neither the reply nor the buffer shrinks to the ticks of one batch.
  • Does the first build keep the ceiling of 25,000 ticks? The measurement of reply size and of read time decides.
  • Can a tick fall between the store and the window, as the two tiers section describes? The measurement of the batch delay against the first reply decides whether the call must wait for the commit of the batch in flight.
  • The live subject names a series by the percent-encoded series URI, and this call names it by id. The client reads both from the series catalogue. Should the live subject carry the id instead, so that one name serves both calls?
  • Should the reply carry the source_id? This page says no. A client that audits a tick reads the store.

6. See also

Emacs 29.3 (Org mode 9.6.15)