Skip to content
academia.sh

Lesson 05 / 19

Time Series Stores

The cost of storing timestamped measurement data under two layouts: the stored-byte difference between a full timestamp per row and a base timestamp per segment with delta coding and block compression, the segment and entry count a range query scans reversing as the range narrows, and a retention policy aligned to the segment boundary bringing entries touched down to zero.

Contents

In the four families so far, the record itself was an entity — a book, a member, a loan — and the last model made the link itself a first-class object. A part of the library’s data, though, is neither entity nor link: the temperature of shelf zones, the count a turnstile records, the value of a loan counter. These are time series — timestamped measurements belonging to a source, arriving in sequence.

This data’s load profile is unlike the other families’. Writing is append-only; a past measurement is almost never updated. Reading targets not a single record but a range. And the data has an expiration date: a minute-level measurement from three months ago enters no question at all. The lesson measures the store-side counterpart of these three properties: the effect of layout on bytes stored, the unit a range query scans, and the cost of a retention policy.

Two Layouts, the Same Measurements

A measurement row is small: a source id, a timestamp, and a single number — the timestamp can be larger than the value it carries. The time series store’s first decision follows from this: measurements are stored not one by one but in segments; each segment is one time bucket for one source, writing its base timestamp once and keeping the rest as deltas.

NS25: twelve shelf zones each carry a temperature sensor, each writing a measurement every 60 seconds, and thirty days of data are examined. NS26: in row layout, a measurement carries a 2-byte source id, an 8-byte timestamp, and an 8-byte value. NS6: the segment unit is a source–day pair.

// time/layout.mjs — the same measurement series serialized in two layouts: (a) a full
// timestamp + value per row, (b) a base timestamp per segment + delta coding + block
// compression. What is counted is bytes stored; the compression is genuinely run, the
// seed is visible.
import { deflateSync } from "node:zlib";
const SERIES = 12, DAYS = 30, INTERVAL = 60_000, T0 = Date.UTC(2024, 5, 1);
const POINTS = 86_400_000 / INTERVAL;            // entries per segment: daily measurement count
let seed = 20240601;                             // visible seed; the generator is self-written
const noise = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648 - 0.5);
const measurement = (s, i) =>                    // integer in tenths of a degree: 18.4 -> 184
  Math.round((18 + s * 0.25 + 3 * Math.sin((i / POINTS) * 2 * Math.PI) + noise() * 0.6) * 10);
const varint = (n) => { const bytes = []; let x = ((n << 1) ^ (n >> 31)) >>> 0;   // zigzag + varint
  do { let y = x & 0x7f; x >>>= 7; if (x) y |= 0x80; bytes.push(y); } while (x); return bytes; };

const rowLayout = Buffer.alloc(SERIES * DAYS * POINTS * 18);   // 2 bytes series + 8 timestamp + 8 value
let offset = 0, rawBytes = 0, segmentBytes = 0, segmentCount = 0;
for (let s = 0; s < SERIES; s += 1) for (let g = 0; g < DAYS; g += 1) {
  const body = [];                               // segment body: varint of the diffs
  let previousDelta = 0, previousValue = null;
  for (let i = 0; i < POINTS; i += 1) {
    const t = T0 + g * 86_400_000 + i * INTERVAL, d = measurement(s, g * POINTS + i);
    rowLayout.writeUInt16LE(s, offset); rowLayout.writeDoubleLE(t, offset + 2);
    rowLayout.writeDoubleLE(d / 10, offset + 10); offset += 18;
    if (previousValue === null) { body.push(...varint(d)); previousValue = d; continue; }
    body.push(...varint(INTERVAL - previousDelta)); previousDelta = INTERVAL;   // delta of the timestamp delta
    body.push(...varint(d - previousValue)); previousValue = d;                 // value delta
  }
  const header = Buffer.alloc(14);               // base timestamp + series + entry count
  header.writeDoubleLE(T0 + g * 86_400_000, 0); header.writeUInt16LE(s, 8); header.writeUInt32LE(POINTS, 10);
  rawBytes += header.length + body.length;
  segmentBytes += header.length + deflateSync(Buffer.from(body)).length;
  segmentCount += 1;
}

const N = SERIES * DAYS * POINTS;
console.log(`${SERIES} series x ${DAYS} days x ${POINTS} measurements = ${N} measurements; ` +
  `${segmentCount} segments, ${POINTS} entries per segment`);
console.log(`\n${"layout".padEnd(38)}${"bytes".padStart(11)}${"per measurement".padStart(17)}${"factor".padStart(7)}`);
for (const [name, b] of [["full timestamp + value per row", rowLayout.length],
    ["segment, delta coding only", rawBytes], ["segment, delta + block compression", segmentBytes]])
  console.log(name.padEnd(38) + String(b).padStart(11) + (b / N).toFixed(2).padStart(17) +
    (rowLayout.length / b).toFixed(2).padStart(7));
12 series x 30 days x 1440 measurements = 518400 measurements; 360 segments, 1440 entries per segment

layout                                      bytes  per measurement factor
full timestamp + value per row            9331200            18.00   1.00
segment, delta coding only                1042560             2.01   8.95
segment, delta + block compression         329013             0.63  28.36

The numbers are of the measurement class: both layouts were genuinely serialized, the compression genuinely run.

The same 518,400 measurements are 9,331,200 bytes under row layout, 329,013 bytes under the segmented layout: 18 bytes per measurement against 0.63 bytes, twenty-eight times. The middle row separates out where the gain comes from. Delta coding alone, without block compression, brings this down to 2.01 bytes per measurement — nine times. The reason is the shape of the data: because the measurement interval is fixed, consecutive timestamps carry the same delta every time, so the delta of the delta is zero and fits in a single byte, and the value delta is a small integer too — an eight-byte timestamp and an eight-byte value collapse to two. Block compression adds another factor of three on top: a repeating sequence of zeros and small deltas is exactly what a compressor works most efficiently on.

The cost sits inside that same decision. A segment is a compressed block; no single measurement inside it can be reached without decompressing the whole segment — the unit of access is not the measurement, it is the segment. And the gain depends on the data being ordered and regularly spaced: if measurements arrive late and out of order, the delta of the delta stops being zero and the segment bloats.

The Time Range Query

This family’s queries almost always carry a time range. Row layout is a single array sorted by time: the start of the range is found by binary search, but the measurements of every source inside the window are read. In segment layout, the metadata — source id and base timestamp — eliminates segments that do not intersect the query without ever opening them.

// time/range.mjs — the same measurement set is queried in two layouts. Row layout is
// a single array sorted by time: the range is found by binary search, and every
// series inside the window is scanned. Segment layout eliminates via metadata
// (series, base timestamp), but an opened segment is resolved whole. The numbers
// are independent of the run.
const SERIES = 12, DAYS = 30, INTERVAL = 60_000, T0 = Date.UTC(2024, 5, 1), POINTS = 1440;
const series = [], timestamps = [];              // row layout: sorted by time
for (let g = 0; g < DAYS; g += 1) for (let i = 0; i < POINTS; i += 1)
  for (let s = 0; s < SERIES; s += 1) { series.push(s); timestamps.push(T0 + g * 86_400_000 + i * INTERVAL); }
const segments = [];                             // segment layout: segment = (series, day)
for (let s = 0; s < SERIES; s += 1) for (let g = 0; g < DAYS; g += 1)
  segments.push({ series: s, base: T0 + g * 86_400_000, end: T0 + (g + 1) * 86_400_000, entries: POINTS });

function rowQuery(s, low, high) {                // binary search + window scan
  let a = 0, b = timestamps.length;
  while (a < b) { const o = (a + b) >> 1; if (timestamps[o] < low) a = o + 1; else b = o; }
  let scanned = 0, result = 0;
  for (let i = a; i < timestamps.length && timestamps[i] < high; i += 1) {
    scanned += 1; if (s === null || series[i] === s) result += 1; }
  return { opened: 0, scanned, result };
}
function segmentQuery(s, low, high) {            // eliminate via metadata, then open the segment
  let opened = 0, scanned = 0, result = 0;
  for (const b of segments) {
    if (s !== null && b.series !== s) continue;
    if (b.end <= low || b.base >= high) continue;
    opened += 1; scanned += b.entries;           // an opened segment is resolved whole
    for (let i = 0; i < b.entries; i += 1) { const t = b.base + i * INTERVAL;
      if (t >= low && t < high) result += 1; }
  }
  return { opened, scanned, result };
}

const JUNE_12 = T0 + 11 * 86_400_000;
const queries = [["shelf 3, June 12 (full day)", 3, JUNE_12, JUNE_12 + 86_400_000],
  ["all shelves, June 12-14", null, JUNE_12, JUNE_12 + 3 * 86_400_000],
  ["shelf 3, June 12 09:00-10:00", 3, JUNE_12 + 9 * 3_600_000, JUNE_12 + 10 * 3_600_000],
  ["shelf 3, the whole month", 3, T0, T0 + DAYS * 86_400_000]];
console.log(`row layout ${timestamps.length} entries; segment layout ${segments.length} segments`);
console.log(`\n${"query".padEnd(35)}${"row: scanned".padStart(15)}${"segments".padStart(9)}` +
  `${"segment: scanned".padStart(17)}${"result".padStart(8)}`);
for (const [name, s, low, high] of queries) {
  const x = rowQuery(s, low, high), y = segmentQuery(s, low, high);
  if (x.result !== y.result) throw new Error("the two paths did not give the same result");
  console.log(name.padEnd(35) + String(x.scanned).padStart(15) + String(y.opened).padStart(9) +
    String(y.scanned).padStart(17) + String(x.result).padStart(8));
}
row layout 518400 entries; segment layout 360 segments

query                                 row: scanned segments segment: scanned  result
shelf 3, June 12 (full day)                  17280        1             1440    1440
all shelves, June 12-14                      51840       36            51840   51840
shelf 3, June 12 09:00-10:00                   720        1             1440      60
shelf 3, the whole month                    518400       30            43200   43200

Three of the four rows favor segment layout; the model’s limit is in the fourth.

A single day for a single shelf scans 17,280 entries under row layout; under segment layout a single segment is opened and 1,440 entries are read. The ratio is exactly the source count: in the sorted array, one shelf’s measurements interleave with the other eleven’s, and that interleaving remains no matter how narrow the range. The same coefficient holds across the whole month for the same shelf: 518,400 entries scanned against 30 segments and 43,200 entries.

The second row shows the case where segmenting gains nothing: when the query wants every shelf, there is no metadata left to eliminate on, both paths read 51,840 entries, and the segment path only adds the work of opening 36 segments.

The third row carries the cost. A one-hour window scans 720 entries under row layout; under segment layout the same question resolves 1,440 entries, because the segment opened is a full day and a compressed block cannot be partially decompressed. The result is 60 measurements either way. A segment’s duration is the range query’s smallest unit of reading: the larger the segment, the greater the compression gain, and the greater the waste for a narrow-range query too. If narrow ranges are asked often, the remedy is storing the measurement a second time at a coarser resolution — this decision is called downsampling, and it is measured in the Case Studies course.

Retention Policy

The last point where time series data departs from the others is deletion. A book record is never deleted; a minute-level measurement stops entering any question past a certain age, and a retention policy drops it. The question is what this dropping costs. NS27: the retention period is seven days.

// time/retention.mjs — the retention policy applied in two layouts. In row layout,
// every expired record is dropped one by one. In segment layout, a segment that has
// fully expired is dropped as a unit; only a segment straddling the boundary is
// rewritten. The numbers are independent of the run.
const SERIES = 12, DAYS = 30, INTERVAL = 60_000, T0 = Date.UTC(2024, 5, 1), POINTS = 1440;
const NOW = T0 + DAYS * 86_400_000;
const timestamps = [];                           // row layout: sorted by time
for (let g = 0; g < DAYS; g += 1) for (let i = 0; i < POINTS; i += 1)
  for (let s = 0; s < SERIES; s += 1) timestamps.push(T0 + g * 86_400_000 + i * INTERVAL);
const segments = [];
for (let s = 0; s < SERIES; s += 1) for (let g = 0; g < DAYS; g += 1)
  segments.push({ base: T0 + g * 86_400_000, end: T0 + (g + 1) * 86_400_000, entries: POINTS });

function rowRetention(limit) {                   // every record is dropped one by one
  let touched = 0, dropped = 0;
  for (const t of timestamps) { touched += 1; if (t < limit) dropped += 1; else break; }
  return { unit: "record", touched, dropped, remaining: timestamps.length - dropped };
}
function segmentRetention(limit) {               // metadata is read, a segment drops as a unit
  let touched = 0, dropped = 0, rewritten = 0;
  for (const b of segments) {
    if (b.end <= limit) { dropped += b.entries; continue; }       // no entry is opened
    if (b.base >= limit) continue;
    touched += b.entries;                                        // segment straddling the boundary
    for (let i = 0; i < b.entries; i += 1)
      if (b.base + i * INTERVAL < limit) dropped += 1; else rewritten += 1;
  }
  return { unit: "segment", touched, dropped, remaining: SERIES * DAYS * POINTS - dropped, rewritten };
}

console.log(`${timestamps.length} measurements, ${segments.length} segments, ${POINTS} entries per segment`);
console.log(`\n${"retention".padEnd(27)}${"layout".padEnd(9)}${"entries touched".padStart(16)}` +
  `${"records dropped".padStart(17)}${"remaining".padStart(10)}`);
for (const [name, duration] of [["7 days (segment boundary)", 7 * 86_400_000],
    ["7 days 12 hours", 7.5 * 86_400_000]]) {
  const limit = NOW - duration;
  for (const r of [rowRetention(limit), segmentRetention(limit)])
    console.log(name.padEnd(27) + r.unit.padEnd(9) + String(r.touched).padStart(16) +
      String(r.dropped).padStart(17) + String(r.remaining).padStart(10));
}
const y = segmentRetention(NOW - 7.5 * 86_400_000);
console.log(`\nsegments straddling the boundary: ${y.touched / POINTS} segments opened, ` +
  `${y.rewritten} entries rewritten`);
518400 measurements, 360 segments, 1440 entries per segment

retention                  layout    entries touched  records dropped remaining
7 days (segment boundary)  record             397441           397440    120960
7 days (segment boundary)  segment                 0           397440    120960
7 days 12 hours            record             388801           388800    129600
7 days 12 hours            segment             17280           388800    129600

segments straddling the boundary: 12 segments opened, 8640 entries rewritten

The first pair is segmenting’s sharpest gain. Both layouts drop the same 397,440 records and leave behind the same 120,960, but row layout touches 397,441 entries to do it: once for every dropped record, plus one more for the record that marks the boundary. In segment layout, entries touched is zero — only 360 segments’ metadata is read, and the 276 expired segments (397,440 ÷ 1,440, a calculation) are dropped as files. Deletion here is not a data operation, it is a file operation.

The zero depends on the retention boundary landing exactly on a segment boundary. The second pair breaks this: at seven and a half days, the boundary cuts through the middle of segments, twelve are opened, 17,280 entries are touched, and 8,640 of them are stored a second time because the segments are rewritten. The dropped record count is again the same on both paths; only the amount of work changes. The rule: when retention is a multiple of segment duration, deletion costs zero; otherwise the cost is the number of segments straddling the boundary times the segment size.

This is the third face of the segment duration decision: the larger the segment, the fewer bytes stored, but the greater the waste for a narrow-range query and the rewriting at the retention boundary. No single value optimizes all three at once.

Summary

  • Time series data is written append-only, read by range, and dropped as it ages; the store folds these three properties into the data model itself.
  • A full timestamp and value cost 18 bytes per row, while a base timestamp plus delta coding brings this to 2.01 bytes, and adding block compression to 0.63 bytes — 9,331,200 bytes against 329,013, a factor of 28.4. The gain depends on the data being ordered and evenly spaced; its cost is that the unit of access is the segment, not the measurement.
  • Segment metadata eliminates in range queries: one shelf’s one day is a single segment and 1,440 entries instead of scanning 17,280. When the query wants every source, there is no elimination, and both paths read 51,840 entries.
  • Segment duration is the smallest unit of reading: a one-hour window resolves 720 entries under row layout, 1,440 under segment layout, and the result is 60 measurements either way.
  • When the retention boundary lands exactly on a segment boundary, 397,440 records are deleted with zero entries touched, 276 segments dropped as files; when it does not, 12 segments are opened, 17,280 entries are touched, and 8,640 entries are rewritten.

Next Step

All five families have now been told in the same order: the model’s distinguishing mechanics, the query classes it cheapens and the ones it makes more expensive. What emerges is not a ranking — every family cheapened one access pattern by making another more expensive, and every measurement showed a row running the other way too. What remains is a decision: which family for the work at hand. The next lesson answers this question not with preference but with criteria — which property of an access pattern points to which family, at what threshold the difference between two families running the same workload starts to matter, and where the cost shows up when the wrong family is chosen.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close