Skip to content

Commit b542530

Browse files
authored
Improve query cache refresh behavior (#117)
1 parent 81a222a commit b542530

12 files changed

Lines changed: 600 additions & 86 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ Freshness is treated asymmetrically (since v1.7.0):
551551
| Partition state | Behavior |
552552
| --- | --- |
553553
| `fresh` | Query proceeds silently. |
554-
| `stale` (cache exists, may be outdated) | Query proceeds; a `warning: querying stale data;` line is written to stderr. Stdout is unchanged. |
554+
| `stale` (cache exists, source changed since refresh) | Query proceeds; a `warning: query cache last refreshed at` line is written to stderr. Stdout is unchanged. |
555555
| `missing` (no cache table/cursor) | Query exits with the exact file-targeted `ctvs query refresh …` command to run when the source file is known. |
556556

557557
Use `ctvs query refresh <file.jsonl>` to refresh selected source files, or

skills/collectivus-query/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Use `ctvs query` to inspect local Collectivus recordings. It reads local JSONL r
1212
1. Run `ctvs query doctor` or `ctvs query status` first to verify the recording root and cache state.
1313
2. If the command cannot find the intended config, discover the service config once with `ctvs status`, a LaunchAgent/systemd unit, or the user, then reuse `--config <path>` only for that setup.
1414
3. Cache freshness is handled asymmetrically:
15-
- **Stale partitions are queried by default** and the CLI prints a `warning: querying stale data; ` line to stderr. Read stderr alongside stdout, and surface any stale warning to the user so they know the data may be outdated. Prefer the file-targeted `ctvs query refresh <file.jsonl>` command the CLI prints when updating cache data; use `--refresh always` only when the query should refresh before it runs.
15+
- **Stale partitions are queried by default** and the CLI prints a `warning: query cache last refreshed at ` line to stderr. Read stderr alongside stdout, and surface the refresh timestamp to the user so they know the cache may not include newer source rows. Prefer the file-targeted `ctvs query refresh <file.jsonl>` command the CLI prints when updating cache data; use `--refresh always` only when the query should refresh before it runs.
1616
- **Missing partitions still error.** Run the exact `ctvs query refresh …` command the CLI prints, or rerun the target query with `--refresh always`.
1717
- Broad manual refreshes are explicit: `ctvs query refresh --all [dataset]`. Do not run a broad refresh when the printed file-targeted command is enough.
1818
- Pass `--strict-freshness` only when the user explicitly needs the pre-1.7 strict mode (e.g., scheduled checks that must never read stale data); it turns stale partitions back into a hard error.
@@ -107,7 +107,7 @@ Use `JSON_VALUE(<col>, '$.path')` to extract scalars from the `attributes` / `st
107107
## Guardrails
108108

109109
- Do not assume the cache auto-refreshes. Query commands default to `--refresh never`, and stale partitions return data with a stderr warning rather than refreshing themselves.
110-
- Always read stderr. A successful exit code does not mean the data is fresh — a `warning: querying stale data; ` line on stderr means stdout reflects outdated cache rows, and the user should be told before drawing conclusions.
110+
- Always read stderr. A successful exit code does not mean the cache is current — a `warning: query cache last refreshed at ` line on stderr means stdout reflects cache rows from that refresh, and the user should be told before drawing conclusions.
111111
- Do not paste `--config` into every command by habit. Use it when discovery shows the service is not using `~/.hyp/collectivus.json`.
112112
- Do not read arbitrary Parquet files directly for `ctvs query sql`; the CLI only allows logical tables.
113113
- Keep SQL read-only and use only logical datasets: `logs`, `traces`, `metrics`, `proxy_messages`, `gascity_messages`, and registered collection tables from `ctvs query catalog`.

skills/collectivus-query/references/query-cli.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The cache is explicit. Query commands do not refresh it unless `--refresh always
1919
Freshness is asymmetric (since v1.7.0):
2020

2121
- `fresh` — query proceeds silently.
22-
- `stale` (cache exists but may be outdated) — query proceeds and writes a `warning: querying stale data; N partition(s) outdated [...] — run '...' to update` line to stderr. Stdout is unchanged.
22+
- `stale` (cache exists, source changed since refresh) — query proceeds and writes a `warning: query cache last refreshed at ...; N partition(s) differ from source [...] — run '...' to refresh` line to stderr. Stdout is unchanged.
2323
- `missing` (no cache table/cursor) — query exits with the exact `ctvs query refresh ...` command to run.
2424

2525
Pass `--strict-freshness` to restore the pre-1.7 behavior where stale partitions are a hard error.

src/cli/query.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -837,7 +837,7 @@ async function ensureCacheReady(paths, scope, parsed) {
837837
const more = stale.length > 3 ? `, +${stale.length - 3} more` : ''
838838
return {
839839
ok: true,
840-
warnings: [`warning: querying stale data; ${stale.length} partition(s) outdated [${summary}${more}] — run '${refreshCommand(parsed, stale, scope)}' to update`],
840+
warnings: [`warning: query cache ${refreshTimeSummary(stale)}; ${stale.length} partition(s) differ from source [${summary}${more}] — run '${refreshCommand(parsed, stale, scope)}' to refresh`],
841841
}
842842
}
843843

@@ -1246,6 +1246,28 @@ function partitionLabel(state) {
12461246
return `${String(partition.dataset)}/${String(partition.gatewayId)}/${String(partition.date)}`
12471247
}
12481248

1249+
/**
1250+
* @param {Array<{ meta?: unknown }>} states
1251+
* @returns {string}
1252+
*/
1253+
function refreshTimeSummary(states) {
1254+
const times = [...new Set(states.map(refreshedAtForState).filter((time) => time !== undefined))].sort()
1255+
if (times.length === 0) return 'refresh time unavailable'
1256+
if (times.length === 1) return `last refreshed at ${times[0]}`
1257+
return `last refreshed between ${times[0]} and ${times[times.length - 1]}`
1258+
}
1259+
1260+
/**
1261+
* @param {{ meta?: unknown }} state
1262+
* @returns {string | undefined}
1263+
*/
1264+
function refreshedAtForState(state) {
1265+
const meta = /** @type {{ refreshed_at?: unknown } | undefined} */ (state.meta)
1266+
return typeof meta?.refreshed_at === 'string' && meta.refreshed_at.length > 0
1267+
? meta.refreshed_at
1268+
: undefined
1269+
}
1270+
12491271
/**
12501272
* @param {number} limit
12511273
* @returns {string}

src/query/iceberg/jsonl.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@ export async function readJsonlEntryBatches(filePath, options = {}, onBatch = ()
2727
const {
2828
startByteOffset = 0,
2929
startLineNumber = 0,
30+
endByteOffset,
3031
batchRows = DEFAULT_BATCH_ROWS,
3132
batchBytes = DEFAULT_BATCH_BYTES,
3233
} = options
3334
const stat = fs.statSync(filePath)
3435
if (startByteOffset > stat.size) {
3536
throw new Error(`source JSONL was truncated: ${filePath}`)
3637
}
38+
const readEndOffset = endByteOffset === undefined
39+
? stat.size
40+
: Math.max(startByteOffset, Math.min(endByteOffset, stat.size))
3741

3842
/** @type {JsonlEntry[]} */
3943
let entries = []
@@ -59,10 +63,10 @@ export async function readJsonlEntryBatches(filePath, options = {}, onBatch = ()
5963
await onBatch(batch)
6064
}
6165

62-
if (startByteOffset < stat.size) {
66+
if (startByteOffset < readEndOffset) {
6367
const stream = fs.createReadStream(filePath, {
6468
start: startByteOffset,
65-
end: stat.size - 1,
69+
end: readEndOffset - 1,
6670
highWaterMark: STREAM_HIGH_WATER_MARK,
6771
})
6872
for await (const chunk of stream) {

src/query/iceberg/store.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'node:fs'
22
import path from 'node:path'
3-
import { fileCatalog, icebergAppend, icebergCreateTable, icebergRead, loadLatestFileCatalogMetadata } from 'icebird'
3+
import { fileCatalog, icebergAppend, icebergCreateTable, icebergDataSource, icebergRead, loadLatestFileCatalogMetadata } from 'icebird'
44
import { createLocalIcebergIO, tableUrlForDir } from './resolver.js'
55
import { icebergSchemaForColumns, rowsToIcebergRecords } from './schema.js'
66

@@ -81,3 +81,36 @@ export async function readRowsFromCursor(cursor) {
8181
const rows = await icebergRead({ tableUrl, metadata, resolver })
8282
return /** @type {Record<string, unknown>[]} */ (rows)
8383
}
84+
85+
/**
86+
* @param {QueryCacheCursor} cursor
87+
* @param {string[]} columns
88+
* @returns {AsyncGenerator<Record<string, unknown>>}
89+
*/
90+
export async function* scanRowsFromCursor(cursor, columns) {
91+
if (!queryCacheTableExists(cursor.table_path)) return
92+
const { resolver, lister } = await createLocalIcebergIO()
93+
const tableUrl = cursor.table_url || queryCacheTableUrl(cursor.table_path)
94+
const { metadata } = await loadLatestFileCatalogMetadata({ tableUrl, resolver, lister })
95+
if (metadata['current-snapshot-id'] === undefined || !metadata.snapshots?.length) return
96+
const source = await icebergDataSource({ tableUrl, metadata, resolver, lister })
97+
const scan = source.scan({ columns })
98+
for await (const row of scan.rows()) {
99+
yield await resolveAsyncRow(row, columns)
100+
}
101+
}
102+
103+
/**
104+
* @param {import('squirreling').AsyncRow} row
105+
* @param {string[]} columns
106+
* @returns {Promise<Record<string, unknown>>}
107+
*/
108+
async function resolveAsyncRow(row, columns) {
109+
/** @type {Record<string, unknown>} */
110+
const out = row.resolved ? { ...row.resolved } : {}
111+
for (const column of columns) {
112+
if (Object.prototype.hasOwnProperty.call(out, column)) continue
113+
out[column] = await row.cells[column]?.()
114+
}
115+
return out
116+
}

src/query/iceberg/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export interface JsonlReadResult {
6262
export interface JsonlReadOptions {
6363
startByteOffset?: number
6464
startLineNumber?: number
65+
endByteOffset?: number
6566
batchRows?: number
6667
batchBytes?: number
6768
}

0 commit comments

Comments
 (0)