Skip to content

Commit b76e39e

Browse files
committed
Add file-targeted query refresh
1 parent 946b0df commit b76e39e

9 files changed

Lines changed: 242 additions & 31 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,8 @@ and [provider fields](https://developers.openai.com/codex/config-reference#model
529529
auto-refresh its Parquet cache unless you ask for that explicitly.
530530

531531
```bash
532-
ctvs query refresh --config collectivus.json
532+
ctvs query refresh /path/to/gw1/logs/2026-05-11.jsonl --config collectivus.json
533+
ctvs query refresh --all logs --config collectivus.json
533534
ctvs query logs --config collectivus.json --since 1h
534535
ctvs query traces slow --config collectivus.json --limit 20
535536
ctvs query metrics series latency.ms --config collectivus.json
@@ -549,9 +550,11 @@ Freshness is treated asymmetrically (since v1.7.0):
549550
| --- | --- |
550551
| `fresh` | Query proceeds silently. |
551552
| `stale` (Parquet exists, may be outdated) | Query proceeds; a `warning: querying stale data; …` line is written to stderr. Stdout is unchanged. |
552-
| `missing` (no Parquet at all) | Query exits with the exact `ctvs query refresh …` command to run. |
553+
| `missing` (no Parquet at all) | Query exits with the exact file-targeted `ctvs query refresh …` command to run when the source file is known. |
553554

554-
Use `--refresh always` to force a refresh before the query runs. Use
555+
Use `ctvs query refresh <file.jsonl>` to refresh selected source files, or
556+
`ctvs query refresh --all [dataset]` when you explicitly want the broader
557+
walk. Use `--refresh always` to force a refresh before the query runs. Use
555558
`--strict-freshness` to restore the pre-1.7 behavior where stale partitions
556559
are a hard error (useful in CI / scheduled jobs that must never read
557560
outdated data).

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Commands default to `~/.hyp/collectivus.json`. If the running gateway or OTEL co
3838
- `--limit <n>`: Maximum rows to render. Default `100`, maximum `1000`.
3939
- `--format <fmt>`: `table`, `json`, `jsonl`, or `markdown`.
4040
- `--refresh <mode>`: `never` or `always`. Default `never`.
41+
- `--all`: Refresh all matching source files for `ctvs query refresh`.
42+
- `--force`: Rebuild fresh cache partitions too for `ctvs query refresh`.
4143
- `--strict-freshness`: Treat stale partitions as a hard error (pre-1.7 behavior). Off by default.
4244

4345
## Commands
@@ -46,7 +48,8 @@ Commands default to `~/.hyp/collectivus.json`. If the running gateway or OTEL co
4648
- `ctvs query status`: Inspect source partitions and cache freshness.
4749
- `ctvs query catalog`: List logical datasets, columns, source partitions, and cached row counts.
4850
- `ctvs query schema <dataset>`: Print static schema for a logical dataset.
49-
- `ctvs query refresh [dataset] [--force]`: Materialize local JSONL into query-cache Parquet.
51+
- `ctvs query refresh <file.jsonl>... [--force]`: Materialize selected JSONL source files into query-cache Parquet.
52+
- `ctvs query refresh --all [dataset] [--force]`: Materialize all matching JSONL source files into query-cache Parquet.
5053
- `ctvs query sample <dataset>`: Show sample rows.
5154
- `ctvs query sql <select-sql>`: Run read-only SQL over logical datasets.
5255
- `ctvs query logs [count|tail]`: List logs, count logs, or tail live JSONL without requiring cache.
@@ -68,7 +71,7 @@ ctvs collect --glob '/path/to/segments/**/*.jsonl' --name segments
6871
ctvs query sql "select * from random_log" --format json
6972
```
7073

71-
`ctvs collect` stores the absolute source path (or glob) and immediately refreshes the Parquet cache. If the source file changes later, normal query freshness rules apply: stale cached data is queryable with a stderr warning, `--strict-freshness` turns that into an error, and `--refresh always` refreshes before running the query.
74+
`ctvs collect` stores the absolute source path (or glob) and immediately refreshes the Parquet cache. If the source file changes later, normal query freshness rules apply: stale cached data is queryable with a stderr warning, `--strict-freshness` turns that into an error, and `ctvs query refresh <file.jsonl>` refreshes selected files. Use `--refresh always` to refresh before running the query.
7275

7376
With `--glob`, one logical table is backed by many source files: each matched file becomes its own cache partition under `.collectivus-query/parquet/collections/<table>/source=<hash>/data.parquet`, and only files whose mtime/size changed re-materialize on refresh. Files that no longer match the glob are pruned from the cache on the next refresh. Inside SQL, use `_ctvs_source_path` to see which file a row came from.
7477

src/cli/init_presets/gascity_skill.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ ORDER BY source, idx;
161161

162162
## Freshness
163163

164-
`session_segments` is glob-backed: new segment files only appear in the cache after a refresh. Run `ctvs query refresh session_segments` to pick up new segments, or use `--refresh always` on any query. Deleted segment files are pruned from the cache on the next refresh.
164+
`session_segments` is glob-backed: new segment files only appear in the cache after a refresh. Run `ctvs query refresh <segment-file.jsonl>` for selected segment files, `ctvs query refresh --all session_segments` to pick up every matching segment, or use `--refresh always` on any query. Deleted segment files are pruned from the cache on the next `--all` refresh.
165165

166166
`events` is append-only single-file; mtime/size changes trigger re-materialization on refresh.
167167

168-
`gascity_messages` is **always fresh** — the daemon writes Parquet directly into the sink (no JSONL stage, no `.meta.json` sidecar), so query-time discovery picks up every part-file the writer has flushed. `ctvs query refresh gascity_messages` is a documented no-op (it lists existing partitions as already-fresh). To pull in newly-flushed rows simply rerun the query.
168+
`gascity_messages` is **always fresh** — the daemon writes Parquet directly into the sink (no JSONL stage, no `.meta.json` sidecar), so query-time discovery picks up every part-file the writer has flushed. `ctvs query refresh --all gascity_messages` is a documented no-op (it lists existing partitions as already-fresh). To pull in newly-flushed rows simply rerun the query.
169169

170170
Full schemas: `ctvs query schema events --format markdown`, `ctvs query schema session_segments --format markdown`, `ctvs query schema gascity_messages --format markdown`. Catalog: `ctvs query catalog --format markdown`.
171171

@@ -176,5 +176,5 @@ Refreshing isn't free. `events.jsonl` and the `session_segments/**/*.jsonl` file
176176
Recommended workflow:
177177

178178
1. Run `ctvs query status` first. The summary shows which date ranges are already cached and which are stale; cheap queries against a covered range never need a refresh.
179-
2. Only invoke `--refresh always` or `ctvs query refresh <dataset>` when a needed date range is missing or `status` reports staleness in the window you care about.
179+
2. Only invoke `--refresh always`, `ctvs query refresh <file.jsonl>`, or `ctvs query refresh --all <dataset>` when a needed date range is missing or `status` reports staleness in the window you care about.
180180
3. Do not reflexively pass `--refresh always` "just in case". Stale-data queries print a warning to stderr (the default behavior); reading that warning is cheaper than re-materializing the cache. Treat refresh as a deliberate step, not a default.

src/cli/query.js

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'node:fs'
2+
import path from 'node:path'
23
import process from 'node:process'
34
import { ConfigError, loadConfigAsync as defaultLoadConfig } from '../config.js'
45
import { defaultConfigPath } from './common.js'
@@ -47,7 +48,8 @@ Commands:
4748
status Inspect JSONL sources and query-cache freshness
4849
catalog List logical datasets and cached row counts
4950
schema <dataset> Print the static logical schema
50-
refresh [dataset] [--force] Materialize local JSONL into query-cache Parquet
51+
refresh <file.jsonl>... Materialize selected JSONL source files into Parquet
52+
refresh --all [dataset] Materialize all matching JSONL into query-cache Parquet
5153
sql <select-sql> Run read-only SELECT SQL over logical datasets
5254
sample <dataset> Show sample rows
5355
doctor Check query prerequisites
@@ -75,6 +77,8 @@ Shared options:
7577
--refresh <mode> never or always (default: never).
7678
Stale partitions query with a stderr warning;
7779
missing partitions always error.
80+
--all Refresh all matching source files (refresh command only)
81+
--force Rebuild fresh cache partitions too (refresh command only)
7882
--strict-freshness Treat stale partitions as errors (pre-1.7 behavior)
7983
--help, -h Show this help`
8084

@@ -191,6 +195,7 @@ export async function runQuery(argv, hooks = {}) {
191195
* limit: number,
192196
* format: QueryFormat,
193197
* refresh: QueryRefreshMode,
198+
* all: boolean,
194199
* force: boolean,
195200
* strictFreshness: boolean,
196201
* error?: string,
@@ -205,12 +210,14 @@ export function parseQueryArgs(argv) {
205210
limit: DEFAULT_LIMIT,
206211
format: 'table',
207212
refresh: 'never',
213+
all: false,
208214
force: false,
209215
strictFreshness: false,
210216
}
211217
for (let i = 0; i < argv.length; i++) {
212218
const arg = argv[i]
213219
if (arg === '--help' || arg === '-h') { out.help = true; return out }
220+
if (arg === '--all') { out.all = true; continue }
214221
if (arg === '--force') { out.force = true; continue }
215222
if (arg === '--strict-freshness') { out.strictFreshness = true; continue }
216223
/**
@@ -393,7 +400,7 @@ function handleSchema(paths, parsed, stdout, stderr) {
393400
}
394401
const meta = readAnyCollectionMeta(paths.parquetDir, collection)
395402
if (!meta) {
396-
stderr.write(`error: query cache is missing for ${collection.table}. Run: ${refreshCommand(parsed)}\n`)
403+
stderr.write(`error: query cache is missing for ${collection.table}. Run: ${refreshCommand(parsed, undefined, { ...baseScope(parsed), datasets: [collection.table] })}\n`)
397404
return 1
398405
}
399406
const rows = meta.columns.map((column) => ({
@@ -414,11 +421,31 @@ function handleSchema(paths, parsed, stdout, stderr) {
414421
* @returns {Promise<number>}
415422
*/
416423
async function handleRefresh(paths, parsed, stdout, stderr) {
417-
const scope = scopeWithOptionalDataset(paths, parsed, parsed.positionals[1])
418424
if (!paths.parquetEnabled || !paths.parquetDir) {
419425
stderr.write('error: query parquet cache is disabled; pass --parquet-dir to refresh explicitly\n')
420426
return 1
421427
}
428+
const targets = parsed.positionals.slice(1)
429+
/** @type {QueryScope} */
430+
let scope
431+
if (parsed.all) {
432+
if (targets.length > 1) {
433+
stderr.write('error: refresh --all accepts at most one dataset\n')
434+
return 2
435+
}
436+
scope = scopeWithOptionalDataset(paths, parsed, targets[0])
437+
} else {
438+
if (targets.length === 0) {
439+
stderr.write('error: refresh requires one or more JSONL files; pass --all to refresh all matching sources\n')
440+
return 2
441+
}
442+
const datasetTarget = targets.length === 1 ? resolveQueryTable(paths, targets[0]) : undefined
443+
if (datasetTarget) {
444+
stderr.write(`error: refresh ${targets[0]} targets a dataset; pass --all to refresh all ${datasetTarget} sources\n`)
445+
return 2
446+
}
447+
scope = refreshScopeForSourceFiles(paths, parsed, targets)
448+
}
422449
const result = await refreshAllCaches({ paths, scope, force: parsed.force, stdout })
423450
if (result.written === 0 && result.skipped === 0 && result.failures === 0) {
424451
stdout.write(`No JSONL files matched in ${paths.recordingRoot}.\n`)
@@ -793,7 +820,7 @@ async function ensureCacheReady(paths, scope, parsed) {
793820
const detail = `${partitionLabel(first)}: missing${first.reason ? ` (${first.reason})` : ''}`
794821
return {
795822
ok: false,
796-
message: `error: query cache is missing for ${detail}. Run: ${refreshCommand(parsed)}`,
823+
message: `error: query cache is missing for ${detail}. Run: ${refreshCommand(parsed, missing, scope)}`,
797824
}
798825
}
799826

@@ -803,14 +830,14 @@ async function ensureCacheReady(paths, scope, parsed) {
803830
const detail = `${partitionLabel(first)}: stale${first.reason ? ` (${first.reason})` : ''}`
804831
return {
805832
ok: false,
806-
message: `error: query cache is stale for ${detail} (--strict-freshness set). Run: ${refreshCommand(parsed)}`,
833+
message: `error: query cache is stale for ${detail} (--strict-freshness set). Run: ${refreshCommand(parsed, stale, scope)}`,
807834
}
808835
}
809836
const summary = stale.slice(0, 3).map((state) => `${partitionLabel(state)}${state.reason ? ` (${state.reason})` : ''}`).join(', ')
810837
const more = stale.length > 3 ? `, +${stale.length - 3} more` : ''
811838
return {
812839
ok: true,
813-
warnings: [`warning: querying stale data; ${stale.length} partition(s) outdated [${summary}${more}] — run '${refreshCommand(parsed)}' to update`],
840+
warnings: [`warning: querying stale data; ${stale.length} partition(s) outdated [${summary}${more}] — run '${refreshCommand(parsed, stale, scope)}' to update`],
814841
}
815842
}
816843

@@ -819,17 +846,50 @@ async function ensureCacheReady(paths, scope, parsed) {
819846

820847
/**
821848
* @param {ReturnType<typeof parseQueryArgs>} parsed
849+
* @param {Array<{ partition: unknown }>} [states]
850+
* @param {QueryScope} [scope]
822851
* @returns {string}
823852
*/
824-
function refreshCommand(parsed) {
853+
function refreshCommand(parsed, states, scope) {
825854
const parts = ['ctvs', 'query', 'refresh']
855+
const sourcePaths = states ? refreshableSourcePaths(states) : []
856+
if (sourcePaths.length > 0 && sourcePaths.length <= 5) {
857+
parts.push(...sourcePaths.map(shellQuote))
858+
} else {
859+
parts.push('--all')
860+
const datasets = scope?.datasets ?? (scope?.dataset ? [scope.dataset] : undefined)
861+
if (datasets?.length === 1) parts.push(datasets[0])
862+
}
826863
if (parsed.configPath) parts.push('--config', shellQuote(parsed.configPath))
827864
if (parsed.parquetDir) parts.push('--parquet-dir', shellQuote(parsed.parquetDir))
828865
if (parsed.gatewayId) parts.push('--gateway-id', shellQuote(parsed.gatewayId))
829866
if (parsed.date) parts.push('--date', parsed.date)
830867
return parts.join(' ')
831868
}
832869

870+
/**
871+
* @param {Array<{ partition: unknown }>} states
872+
* @returns {string[]}
873+
*/
874+
function refreshableSourcePaths(states) {
875+
const seen = new Set()
876+
/** @type {string[]} */
877+
const out = []
878+
for (const state of states) {
879+
const { partition: rawPartition } = state
880+
const partition = /** @type {Record<string, unknown>} */ (rawPartition)
881+
const sourcePath = partition.jsonlPath
882+
if (typeof sourcePath !== 'string') continue
883+
if (!sourcePath.endsWith('.jsonl')) continue
884+
if (!fs.existsSync(sourcePath)) continue
885+
const abs = path.resolve(sourcePath)
886+
if (seen.has(abs)) continue
887+
seen.add(abs)
888+
out.push(abs)
889+
}
890+
return out
891+
}
892+
833893
/**
834894
* @param {QueryPaths} paths
835895
* @param {ReturnType<typeof parseQueryArgs>} parsed
@@ -1097,6 +1157,53 @@ function scopeWithOptionalDataset(paths, parsed, rawDataset) {
10971157
return scope
10981158
}
10991159

1160+
/**
1161+
* @param {QueryPaths} paths
1162+
* @param {ReturnType<typeof parseQueryArgs>} parsed
1163+
* @param {string[]} targets
1164+
* @returns {QueryScope}
1165+
*/
1166+
function refreshScopeForSourceFiles(paths, parsed, targets) {
1167+
/** @type {string[]} */
1168+
const sourcePaths = []
1169+
const seenTargets = new Set()
1170+
for (const target of targets) {
1171+
const abs = path.resolve(target)
1172+
if (seenTargets.has(abs)) continue
1173+
seenTargets.add(abs)
1174+
sourcePaths.push(abs)
1175+
}
1176+
1177+
const scope = { ...baseScope(parsed), sourcePaths }
1178+
const matched = new Set()
1179+
const datasets = new Set()
1180+
for (const source of discoverSourceFiles(paths.recordingRoot, scope)) {
1181+
matched.add(path.resolve(source.jsonlPath))
1182+
datasets.add(datasetForSourceSignal(source.signal))
1183+
}
1184+
for (const partition of expectedCollectionPartitions(paths, scope)) {
1185+
matched.add(path.resolve(partition.jsonlPath))
1186+
datasets.add(partition.table)
1187+
}
1188+
1189+
const unknown = sourcePaths.filter((sourcePath) => !matched.has(sourcePath))
1190+
if (unknown.length > 0) {
1191+
throw new Error(`refresh source file is not a known recording or collection source: ${unknown[0]}`)
1192+
}
1193+
if (datasets.size === 0) {
1194+
throw new Error('no refreshable source files matched')
1195+
}
1196+
return { ...scope, datasets: [...datasets] }
1197+
}
1198+
1199+
/**
1200+
* @param {import('../query/types.js').SourceFile['signal']} signal
1201+
* @returns {string}
1202+
*/
1203+
function datasetForSourceSignal(signal) {
1204+
return signal === 'proxy' ? 'proxy_messages' : signal
1205+
}
1206+
11001207
/**
11011208
* @param {QueryPaths} paths
11021209
* @param {string} raw

src/query/collections.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,11 @@ export function expectedCollectionPartitions(paths, scope) {
277277
if (!paths.parquetDir) return []
278278
const manifest = readCollectionsManifest(paths.recordingRoot)
279279
const wanted = collectionTablesForScope(manifest, scope)
280-
const parquetDir = paths.parquetDir
281-
return wanted.flatMap((collection) => collectionPartitionsFor(parquetDir, collection))
280+
const { parquetDir } = paths
281+
const sourcePaths = sourcePathFilter(scope)
282+
const partitions = wanted.flatMap((collection) => collectionPartitionsFor(parquetDir, collection))
283+
if (!sourcePaths) return partitions
284+
return partitions.filter((partition) => sourcePaths.has(path.resolve(partition.jsonlPath)))
282285
}
283286

284287
/**
@@ -540,6 +543,7 @@ export function readCollectionCacheMeta(metaPath) {
540543
*/
541544
function pruneOrphanCollectionPartitions(paths, scope, stdout) {
542545
if (!paths.parquetDir) return
546+
if (scope.sourcePaths && scope.sourcePaths.length > 0) return
543547
const manifest = readCollectionsManifest(paths.recordingRoot)
544548
const wanted = collectionTablesForScope(manifest, scope)
545549
for (const collection of wanted) {
@@ -894,6 +898,15 @@ function collectionTablesForScope(manifest, scope) {
894898
return all.filter((collection) => wanted.has(collection.table))
895899
}
896900

901+
/**
902+
* @param {QueryScope} scope
903+
* @returns {Set<string> | undefined}
904+
*/
905+
function sourcePathFilter(scope) {
906+
if (!scope.sourcePaths) return undefined
907+
return new Set(scope.sourcePaths.map((sourcePath) => path.resolve(sourcePath)))
908+
}
909+
897910
/**
898911
* @returns {CollectionsManifest}
899912
*/

src/query/paths.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export function discoverSourceFiles(root, scope) {
150150
const wantedSignals = datasets
151151
? new Set(datasets.map((dataset) => sourceSignalForDataset(dataset)))
152152
: new Set(['logs', 'traces', 'metrics', 'proxy'])
153+
const sourcePaths = sourcePathFilter(scope)
153154
/** @type {SourceFile[]} */
154155
const files = []
155156
const gatewayIds = scope.gatewayId ? [scope.gatewayId] : safeReadDir(root)
@@ -166,6 +167,7 @@ export function discoverSourceFiles(root, scope) {
166167
const date = match[1]
167168
if (scope.date && date !== scope.date) continue
168169
const jsonlPath = path.join(signalDir, entry)
170+
if (sourcePaths && !sourcePaths.has(path.resolve(jsonlPath))) continue
169171
const stat = safeStat(jsonlPath)
170172
if (!stat || !stat.isFile()) continue
171173
files.push({
@@ -430,6 +432,7 @@ function staleReason(partition, meta) {
430432
export function listCacheMetas(parquetDir, scope) {
431433
const requestedDatasets = scope.datasets ?? (scope.dataset ? [scope.dataset] : undefined)
432434
const datasets = requestedDatasets ? requestedDatasets.filter(isQueryDataset) : QUERY_DATASETS
435+
const sourcePaths = sourcePathFilter(scope)
433436
/** @type {CacheMeta[]} */
434437
const out = []
435438
for (const dataset of datasets) {
@@ -446,6 +449,7 @@ export function listCacheMetas(parquetDir, scope) {
446449
const date = dateMatch[1]
447450
if (scope.date && date !== scope.date) continue
448451
const meta = readCacheMeta(path.join(gatewayDir, dateEntry, 'data.parquet.meta.json'))
452+
if (meta && sourcePaths && !sourcePaths.has(path.resolve(meta.source_path))) continue
449453
if (meta) out.push(meta)
450454
}
451455
}
@@ -513,6 +517,15 @@ function isFile(p) {
513517
return safeStat(p)?.isFile() ?? false
514518
}
515519

520+
/**
521+
* @param {QueryScope} scope
522+
* @returns {Set<string> | undefined}
523+
*/
524+
function sourcePathFilter(scope) {
525+
if (!scope.sourcePaths) return undefined
526+
return new Set(scope.sourcePaths.map((sourcePath) => path.resolve(sourcePath)))
527+
}
528+
516529
/**
517530
* @param {SourceFile} a
518531
* @param {SourceFile} b

src/query/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export type QueryRefreshMode = 'never' | 'always'
1414
export interface QueryScope {
1515
dataset?: string
1616
datasets?: string[]
17+
/** Absolute source JSONL paths to include when refreshing or inspecting a narrow cache scope. */
18+
sourcePaths?: string[]
1719
gatewayId?: string
1820
date?: string
1921
from?: string

0 commit comments

Comments
 (0)