UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

454 lines (410 loc) 19.4 kB
/** * # adapters/lake/objects — byte-level object access, with a BOUNDED local cache * * The sibling {@link ./index.ts Lake} accessor is Parquet-over-chDB: it answers SQL. This module * answers the other half of the same question — *"give me the BYTES of one lake object"* — for the * artifacts that are not Parquet and never will be: the benchmark's `.jsonl` tape corpus, the raw * vendor pulls (`.csv.zst`, `.json`), the normalized sidecars. Same {@link LakeConfig}, same * env, same doctrine: * * > **Object storage is the ONLY source of truth. Local disk is a bounded, evictable cache.** * * That doctrine is not decoration here — it is the fix for a real incident. The 14 GiB tape corpus * used to live on the developer's disk under `data/tape-corpus/`, and a single Databento pull filled * the disk. The bytes now live on R2; this module is how the benchmark reads them back **without * ever reconstituting that 14 GiB pile**: the cache is LRU-evicted to `LAKE_CACHE_MAX_BYTES`, so the * working set is bounded by construction no matter how many objects are touched. * * ## Why synchronous * Every corpus loader in the benchmark is a pure, synchronous function of on-disk bytes * (`loadRealFomcTape`, `realTapePathFor`, the `.gen.ts` converters) and is called at module scope to * decide whether a suite runs or SKIPs. Threading `await` through all of that would be a large, * risk-bearing refactor of code whose determinism the grade path depends on. So {@link ObjectStore} * is **sync**: the network fetch is a `spawnSync` of `curl` with SigV4 (`--aws-sigv4`), creds fed on * **stdin** (never argv — they would otherwise be visible in `ps`). No new dependency: `curl` is * already the transport the pull scripts use, and it is the only sync HTTP the runtime has. * * ## The integrity contract (unweakened) * A caller that knows an object's committed `sha256` pin passes it. The fetched body is hashed * **before** it is committed to the cache; a mismatch is deleted and thrown ({@link LakeReadError}) — * a corrupt or drifted object can never enter the cache, and can never be silently served. This is * *additional* to (never a replacement for) the pin check the corpus loaders already perform on the * bytes they read: the tape is verified at fetch AND at load. * * ## Absence is a SKIP, corruption is a THROW * A genuinely absent object (HTTP 404), an unreachable store (no creds, no network, no `curl`), or * an explicitly `offline` store all resolve to `null` — the corpus-dependent suites then SKIP * visibly, exactly as they did when the corpus was simply missing from disk. Only a **sha mismatch** * throws. Unreachability is reported once on `stderr` and retained on {@link ObjectStore.lastError} * so a mis-set credential is legible rather than a silent universal skip. * * ## Never hangs * `curl` is bounded by `--connect-timeout` and `--max-time`, and `spawnSync` carries its own hard * `timeout` on top. (Note for the reader who reaches for the shell: **macOS has no `timeout` * binary** — the `coreutils` one is `gtimeout`. Wrapping a probe in `timeout` there silently no-ops. * The bounds must come from `curl`'s own flags, as they do here.) */ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { closeSync, mkdirSync, openSync, readSync, readdirSync, renameSync, rmSync, statSync, utimesSync, } from "node:fs"; import { join } from "node:path"; import { resolveLakeConfig, type LakeConfig, type LakeConfigInput, type LakeS3Config } from "./config.ts"; import { LakeConfigError, LakeReadError, type LakeSource } from "./errors.ts"; /** Default per-object connect budget (seconds). */ const DEFAULT_CONNECT_TIMEOUT_S = 15; /** Default per-object total transfer budget (seconds) — the corpus holds multi-hundred-MB objects. */ const DEFAULT_MAX_TIME_S = 900; /** One resolved lake object: a local path the caller may read with plain `fs`. */ export interface LakeObject { /** Absolute local path to the object's bytes (a cache segment, or the dev-local file itself). */ readonly path: string; /** Size in bytes. */ readonly bytes: number; /** True when it was served warm — no network touched. */ readonly cached: boolean; /** The canonical source. `"s3"` even when served warm: the cache is transparent, not a source. */ readonly source: LakeSource; } /** Per-fetch options. */ export interface EnsureOptions { /** * The object's committed sha256 pin. When given, a fetched body is verified against it BEFORE it * is committed to the cache; a mismatch throws {@link LakeReadError} and caches nothing. */ readonly sha256?: string; } /** Construction options: the whole {@link LakeConfigInput} surface, plus an offline latch. */ export interface ObjectStoreOptions extends LakeConfigInput { /** Never touch the network: serve only the dev-local root and an already-warm cache. */ readonly offline?: boolean; /** Per-object connect budget in seconds (default {@link DEFAULT_CONNECT_TIMEOUT_S}). */ readonly connectTimeoutS?: number; /** Per-object total transfer budget in seconds (default {@link DEFAULT_MAX_TIME_S}). */ readonly maxTimeS?: number; } /** The byte-level object accessor over the lake. */ export interface ObjectStore { /** The resolved lake configuration (holds secrets in memory; never serialize it). */ readonly config: LakeConfig; /** Where cache segments live (`<cacheDir>/objects`). */ readonly cacheRoot: string; /** False when constructed `offline`, or when no S3 remote is configured. */ readonly online: boolean; /** The last unreachability error (bad creds, network, missing `curl`), if any. Never a 404. */ readonly lastError: Error | undefined; /** * Resolve one lake-relative key to local bytes: dev-local root → warm cache → fetch from object * storage. Returns `null` when the object is absent or the store is unreachable (the caller * SKIPs). Throws {@link LakeReadError} only on a **sha mismatch** — the integrity contract. */ ensure(rel: string, opts?: EnsureOptions): LakeObject | null; /** Total bytes currently held in the cache. */ cacheBytes(): number; /** * LRU-evict (by access time) until the cache is within `config.cacheMaxBytes`. `protect` is never * evicted — it is the object being served right now. Returns the bytes freed. */ evict(protect?: string): number; } // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── /** Reject a relative key that is empty or escapes the lake root; return it normalized. */ function checkRel(rel: string): string { const r = rel.trim(); if (r.length === 0) throw new LakeReadError("empty relative key"); if (r.startsWith("/") || r.split("/").includes("..")) { throw new LakeReadError(`relative key must stay inside the lake: ${JSON.stringify(rel)}`, { rel }); } return r; } /** `${endpoint}/${bucket}/${prefix}/${rel}`, each key segment percent-encoded, no doubled slashes. */ function objectUrl(s3: LakeS3Config, rel: string): string { const key = [s3.prefix, rel] .filter((p) => p.length > 0) .join("/") .split("/") .map(encodeURIComponent) .join("/"); return `${s3.endpoint}/${encodeURIComponent(s3.bucket)}/${key}`; } const isFile = (abs: string): boolean => { try { return statSync(abs).isFile(); } catch { return false; } }; const sizeOf = (abs: string): number => { try { return statSync(abs).size; } catch { return 0; } }; /** * sha256 of a FILE, read in bounded chunks — never buffers the whole object. The corpus holds * objects in the hundreds of MB (and raw pulls in the GB); hashing must not itself be the thing that * exhausts memory. */ export function sha256File(abs: string): string { const h = createHash("sha256"); const buf = Buffer.allocUnsafe(1 << 20); // 1 MiB const fd = openSync(abs, "r"); try { for (;;) { const n = readSync(fd, buf, 0, buf.length, null); if (n <= 0) break; h.update(buf.subarray(0, n)); } } finally { closeSync(fd); } return h.digest("hex"); } /** A stable, secret-free, collision-free cache-segment name for an object URL. */ function segmentName(url: string, rel: string): string { const digest = createHash("sha256").update(url).digest("hex").slice(0, 40); // Keep a legible suffix so a human can see what a cache file IS, without letting the key drive the path. const leaf = (rel.split("/").pop() ?? "object").replace(/[^A-Za-z0-9._-]/g, "_").slice(-64); return `${digest}-${leaf}`; } /** curl config-file syntax is quote-delimited; a credential carrying `"`/newline could break out. */ function assertCredSafe(value: string, what: string): void { if (/["\r\n\\]/.test(value)) { throw new LakeConfigError(`${what} contains a character that cannot be passed to curl safely`); } } // ───────────────────────────────────────────────────────────────────────────── // Factory // ───────────────────────────────────────────────────────────────────────────── /** {@link resolveLakeConfig} with the rclone remote suppressed — for when that remote won't resolve. */ function withoutRcloneRemote(input: ObjectStoreOptions): LakeConfig { const env = input.env ?? process.env; return resolveLakeConfig({ ...input, rcloneRemote: "", env: { ...env, LAKE_RCLONE_REMOTE: "" } }); } /** * Resolve the config for an object store **without the rclone lookup being able to throw**. * * `resolveLakeConfig` is fail-closed for the SQL accessor: it throws when a named rclone remote is * unreadable. That is right for `lake()`, and wrong here. Resolving a remote READS A CREDENTIAL * FILE, and this module's contract is that no creds ⇒ unreachable ⇒ `null` ⇒ the caller SKIPs — so * the throw fired from inside probes whose only documented answers are an object or `null`, and a * machine with no `~/.config/rclone/rclone.conf` (i.e. CI) could never reach its own skip path. * * A remote that DOES resolve is honoured in full, so a developer keeps their warm cache (the cache * segment name is derived from the endpoint). One that does not is reported, not swallowed: the * caller re-surfaces it through {@link ObjectStore.lastError} + a one-shot stderr line, so a * mis-set credential stays legible rather than becoming a silent universal skip. */ function resolveForStore(input: ObjectStoreOptions): { config: LakeConfig; configError: LakeConfigError | undefined; } { try { return { config: resolveLakeConfig(input), configError: undefined }; } catch (err) { if (!(err instanceof LakeConfigError)) throw err; return { config: withoutRcloneRemote(input), configError: err }; } } /** Construct an {@link ObjectStore} from an optional override (falls back to the environment). */ export function objectStore(input: ObjectStoreOptions = {}): ObjectStore { // The offline latch is legible BEFORE anything that could fail: `offline` promises "no network, no // credential", so an absent credential file must be a non-event rather than the first thing to // throw. Resolution below cannot throw at all ({@link resolveForStore}) — that is what lets this // latch do what it documents and force the skip path deterministically. const offline = input.offline === true; const { config, configError } = resolveForStore(input); const env = input.env ?? process.env; const cacheRoot = join(config.cacheDir, "objects"); const connectTimeoutS = input.connectTimeoutS ?? DEFAULT_CONNECT_TIMEOUT_S; const maxTimeS = input.maxTimeS ?? DEFAULT_MAX_TIME_S; const region = env.LAKE_S3_REGION !== undefined && env.LAKE_S3_REGION !== "" ? env.LAKE_S3_REGION : "auto"; const online = !offline && config.s3 !== undefined; let lastError: Error | undefined; let warned = false; /** Report unreachability ONCE, loudly enough that a mis-set credential is not a silent skip. */ function unreachable(err: Error): null { lastError = err; if (!warned) { warned = true; console.error(`[lake] object storage unreachable — corpus reads will SKIP: ${err.message}`); } return null; } // An rclone remote that would not resolve, and nothing else supplied a remote: this store is // unreachable. Say so ONCE, now — the alternative is a suite that skips universally and silently // because of a typo'd credential path. Silent when `offline`: no credential was ever wanted. if (configError !== undefined && !offline && config.s3 === undefined) unreachable(configError); function listSegments(): { path: string; size: number; atime: number }[] { try { return (readdirSync(cacheRoot) as string[]) .filter((f) => !f.endsWith(".tmp")) .map((f) => { const p = join(cacheRoot, f); const st = statSync(p); return { path: p, size: st.size, atime: st.atimeMs }; }) .filter((e) => e.size >= 0); } catch { return []; } } function cacheBytes(): number { return listSegments().reduce((a, e) => a + e.size, 0); } /** * LRU-evict to the cap. `protect` (the object being served right now) is never evicted, so the * cache is bounded by `cacheMaxBytes` PLUS at most that one in-flight object — the bound holds * even for an object larger than the whole cap, which is served but never accumulates. */ function evict(protect?: string): number { const cap = config.cacheMaxBytes; const entries = listSegments(); let total = entries.reduce((a, e) => a + e.size, 0); if (total <= cap) return 0; entries.sort((a, b) => a.atime - b.atime); // oldest-accessed evicted first let freed = 0; for (const e of entries) { if (total <= cap) break; if (protect !== undefined && e.path === protect) continue; try { rmSync(e.path, { force: true }); total -= e.size; freed += e.size; } catch { /* a racing reader may hold it; skip */ } } return freed; } /** Fetch `url` to `dest` with SigV4. Returns null (unreachable/absent) or throws nothing. */ function fetchTo(s3: LakeS3Config, url: string, dest: string, rel: string): "ok" | "absent" | "unreachable" { assertCredSafe(s3.accessKeyId, "access key id"); assertCredSafe(s3.secretAccessKey, "secret access key"); const res = spawnSync( "curl", [ "--silent", "--show-error", "--fail", // any HTTP >= 400 is a failure, not a 0-byte "success" "--location", "--config", "-", // credentials arrive on STDIN, never on argv (argv is world-readable in `ps`) "--aws-sigv4", `aws:amz:${region}:s3`, "--connect-timeout", String(connectTimeoutS), "--max-time", String(maxTimeS), "--retry", "2", "--retry-connrefused", "--write-out", "%{http_code}", "--output", dest, url, ], { input: `user = "${s3.accessKeyId}:${s3.secretAccessKey}"\n`, encoding: "utf8", timeout: (maxTimeS + 30) * 1000, // a hard backstop over curl's own budget — never hang maxBuffer: 1 << 20, }, ); if (res.error !== undefined && res.error !== null) { rmSync(dest, { force: true }); unreachable(new LakeReadError(`could not run curl to reach object storage: ${res.error.message}`, { rel, source: "s3", cause: res.error })); return "unreachable"; } if (res.status === 0) return "ok"; rmSync(dest, { force: true }); const code = (res.stdout ?? "").trim(); if (code === "404") return "absent"; // a legitimately absent object → the caller SKIPs const detail = (res.stderr ?? "").trim(); unreachable( new LakeReadError( `object storage read failed (curl exit ${String(res.status)}, http ${code || "n/a"}): ${rel}${detail === "" ? "" : ` — ${detail}`}`, { rel, source: "s3" }, ), ); return "unreachable"; } function ensure(rel: string, opts: EnsureOptions = {}): LakeObject | null { const key = checkRel(rel); // 1. The explicit dev/fixture root (creds-free tests/CI, and the warm local corpus). if (config.devLocalRoot !== undefined) { const abs = join(config.devLocalRoot, key); if (isFile(abs)) return { path: abs, bytes: sizeOf(abs), cached: true, source: "dev-local" }; } if (config.s3 === undefined) return null; // nothing to reach; SKIP const s3 = config.s3; const url = objectUrl(s3, key); const segment = join(cacheRoot, segmentName(url, key)); // 2. A warm cache segment — no network, and no re-hash: it was verified when it was written. // (A pinned caller re-verifies on read regardless, so on-disk rot is still caught loudly.) if (isFile(segment)) { try { const now = new Date(); utimesSync(segment, now, now); // bump recency for the LRU } catch { /* best-effort */ } return { path: segment, bytes: sizeOf(segment), cached: true, source: "s3" }; } if (!online) return null; // offline latch, cache missed → SKIP // 3. Cold: fetch once from object storage, verify, commit, then bring the cache back under cap. try { mkdirSync(cacheRoot, { recursive: true }); } catch (cause) { return unreachable(new LakeReadError(`cache dir unusable: ${cacheRoot}`, { rel: key, source: "s3", cause })); } const tmp = `${segment}.${String(process.pid)}.${String(Date.now())}.tmp`; const outcome = fetchTo(s3, url, tmp, key); if (outcome !== "ok") return null; // The integrity contract: verify BEFORE the object can ever be served from the cache. const pin = opts.sha256; if (pin !== undefined && pin !== "") { const got = sha256File(tmp); if (got !== pin) { rmSync(tmp, { force: true }); throw new LakeReadError( `lake object ${key} sha256 ${got} != pinned ${pin} — the object drifted from its committed pin; ` + "NOT cached. Re-vet the corpus before re-pinning (a changed object is a NEW object).", { rel: key, source: "s3" }, ); } } try { renameSync(tmp, segment); } catch (cause) { rmSync(tmp, { force: true }); throw new LakeReadError(`could not commit cache segment for ${key}`, { rel: key, source: "s3", cause }); } evict(segment); return { path: segment, bytes: sizeOf(segment), cached: false, source: "s3" }; } return { config, cacheRoot, online, get lastError(): Error | undefined { return lastError; }, ensure, cacheBytes, evict, }; }