Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/theonetrade/backtest-kit-minio-s3-docker/llms.txt

Use this file to discover all available pages before exploring further.

S3 lists objects lexicographically — it has no concept of insertion time and cannot answer “what were the last N records added?” without scanning the entire prefix. For Log, Notification, and Storage entries (the three entities whose readXData methods return a newest-first window), a Redis index sidesteps this limitation. The design is deliberately lightweight: no sorted sets, no Lua scripts — just plain per-minute Redis SETs and a single floor key.
Only Log, Notification, and Storage use the Redis time index. The remaining 13 adapters read directly from MinIO using deterministic keys and never touch Redis for listings.

Per-Minute SET Model

Each of the three connection services — LogConnectionService, NotificationConnectionService, and StorageConnectionService — maintains its own namespace of Redis keys. For Log entries, the connection key is log-items-connection; for Notifications, notification-items-connection; for Storage, storage-items-connection. When a new object is written to MinIO, register(objectName) is called immediately afterward:
LogConnectionService.ts
const REDIS_KEY = "log-items-connection";
const MS_PER_MINUTE = 60_000;
const TIMESTAMP_PAD = String(Number.MAX_SAFE_INTEGER).length;

const GET_MINUTE_KEY_FN = (connectionKey: string, minute: number) =>
  `${connectionKey}:${String(minute).padStart(TIMESTAMP_PAD, "0")}`;

const GET_FLOOR_KEY_FN = (connectionKey: string) =>
  `${connectionKey}:floor`;

public register = async (objectName: string): Promise<void> => {
  const redis = await getRedis();
  // alignToInterval rounds the current wall-clock time down to the
  // nearest minute boundary — re-registering within a minute
  // deduplicates by SADD construction.
  const minute = alignToInterval(new Date(), "1m").getTime();
  await redis
    .pipeline()
    .sadd(GET_MINUTE_KEY_FN(this.connectionKey, minute), objectName)
    .setnx(GET_FLOOR_KEY_FN(this.connectionKey), String(minute))
    .exec();
};
Two things happen atomically in a single pipeline:
  1. SADD adds objectName to the SET for the current minute bucket. If the same name is registered twice within the same minute (e.g. during a fast backtest replay), SADD deduplicates it automatically.
  2. SETNX writes the floor marker — the timestamp of the very first minute that ever had a registration — but only if it has not been set before. This bounds the backwards walk in listNewest so it never scans into empty history.
The minute key format zero-pads the aligned minute millisecond timestamp to TIMESTAMP_PAD characters (16 digits). For example, a minute boundary near timestamp 1700000000000 produces a 16-digit key:
log-items-connection:0001700000000000   ← minute bucket SET (raw aligned-minute, zero-padded)
log-items-connection:floor             ← first-ever minute (plain timestamp string)
Note that minute bucket keys use raw aligned timestamps (not inverted). Only object names stored inside the SETs use the inverted scheme (MAX_SAFE_INTEGER − ms) for newest-first lexicographic ordering.

listNewest(limit, prefix): Backwards Walk

listNewest reconstructs the most recent limit object names by walking backwards from the current minute to the floor marker, probing minutes in batches of 1 000 to minimise round trips:
LogConnectionService.ts
public listNewest = async (limit: number, prefix = ""): Promise<string[]> => {
  const redis = await getRedis();
  const floorRaw = await redis.get(GET_FLOOR_KEY_FN(this.connectionKey));
  if (!floorRaw) {
    return [];   // index is empty — caller falls back to bucket LIST
  }
  const floor = Number(floorRaw);
  let minute = alignToInterval(new Date(), "1m").getTime();
  const seen = new Set<string>();
  const names: string[] = [];

  while (minute >= floor && names.length < limit) {
    // Build a batch of up to 1 000 minute timestamps to probe
    const batch: number[] = [];
    while (batch.length < 1_000 && minute >= floor) {
      batch.push(minute);
      minute -= MS_PER_MINUTE;
    }

    // Single pipeline: SCARD for each minute in the batch
    // Empty minutes cost zero member transfers
    const cardPipeline = redis.pipeline();
    for (const ts of batch) {
      cardPipeline.scard(GET_MINUTE_KEY_FN(this.connectionKey, ts));
    }
    const cards = await cardPipeline.exec();
    if (!cards) break;

    for (let i = 0; i < batch.length; i++) {
      const [error, card] = cards[i];
      if (error || !card) continue;

      const minuteKey = GET_MINUTE_KEY_FN(this.connectionKey, batch[i]);

      if ((card as number) <= 1_000) {
        // Small set: fetch all members in one SMEMBERS
        if (collect(await redis.smembers(minuteKey))) return names;
        continue;
      }

      // Hot minute: page through SSCAN to avoid a giant SMEMBERS response
      let cursor: string | number = 0;
      while (true) {
        const [nextCursor, members] = await redis.sscan(
          minuteKey, cursor, "COUNT", 1_000
        );
        cursor = nextCursor;
        if (collect(members)) return names;
        if (cursor === "0" || cursor === 0) break;
      }
    }
  }
  return names;
};
The collect helper applies the optional prefix filter, deduplicates via a seen Set, and returns true (early exit) once limit is satisfied.

Why SCARD First?

A minute that had no registrations is simply absent from Redis — SCARD returns 0. By pipelining 1 000 SCARD commands in a single round trip, the walk can skip an entire year of empty minutes (525 600 minutes) in ~526 round trips, each skipping 1 000 empty minutes for free. Contrast this with a naive SCAN over the full keyspace, which would transfer thousands of key names just to find the populated ones.

Hot-Minute Handling

During a fast backtest replay, many log entries can land in the same wall-clock minute. A minute SET with more than 1 000 members is considered “hot” and paged with SSCAN + early exit, so the caller never needs to pull an entire hot-minute’s worth of names when limit is already satisfied.

Floor Marker

The floor key <entity>-connection:floor stores the millisecond timestamp (as a string) of the first-ever registration. The backwards walk terminates at this boundary — it never descends below it — which means the walk cost is proportional to the age of the most recent limit entries, not to the total history in the bucket.
log-items-connection:floor → "1700000000000"
SETNX ensures the floor is written only once and never overwritten, even if register is called millions of times afterward.

Cold-Index Fallback

If Redis is flushed (restarted with --no-appendonly, replaced with a new container, or explicitly FLUSHALL-ed), listNewest returns an empty array because the floor key is absent. LogDataService.listAll detects this and falls back to a direct MinIO bucket listing:
LogDataService.ts
public listAll = async (): Promise<ILogRow[]> => {
  const rows: ILogRow[] = [];
  const names = await this.logConnectionService.listNewest(LIST_LIMIT);

  if (names.length) {
    // Fast path: Redis index is warm
    for (const name of names) {
      const row = await this.get<ILogRow>(name);
      if (row) rows.push(row);
    }
  } else {
    // Cold path: Redis was flushed — fall back to bucket LIST
    // Object keys embed an inverted timestamp, so lexicographic listing
    // is already newest-first; re-warm the index while reading
    for await (const value of this.values("", LIST_LIMIT)) {
      const row = value as ILogRow;
      rows.push(row);
      await this.logConnectionService.register(row.id);  // re-warm
      if (rows.length >= LIST_LIMIT) break;
    }
  }

  rows.sort(
    (a, b) => new Date(b.createDate).getTime() - new Date(a.createDate).getTime()
  );
  return rows.slice(0, LIST_LIMIT);
};
Because object keys embed an inverted timestamp (MAX_SAFE_INTEGER − ms, zero-padded), listObjectsV2 naturally returns them in newest-first lexicographic order. The cold path reads the most recent window and rebuilds the Redis index for the next call — one cold listing is all it takes to recover full performance.
Steady-state cost of readLogData at startup: 1 RTT for the floor key GET + 1–2 pipeline RTTs for SCARD probes + ≤200 point GETs for the log row bodies — independent of how many total objects the log-items/ prefix holds. The Redis index keeps listing cost bounded at O(limit), not O(bucket size).

Redis Key Summary

Key patternTypeContentTTL
log-items-connection:<minute>SETObject names registered in that minutenone
log-items-connection:floorStringEarliest minute timestamp ever registerednone
notification-items-connection:<minute>SETNotification object namesnone
notification-items-connection:floorStringEarliest notification minutenone
storage-items-connection:<minute>SETStorage object namesnone
storage-items-connection:floorStringEarliest storage minutenone
All connection keys are created with no TTL (ttlExpireSeconds = -1 in the BaseMap constructor), so they persist until Redis is flushed or the process explicitly clears them.

Build docs developers (and LLMs) love