Skip to content

Commit ca5a355

Browse files
committed
Merge remote-tracking branch 'origin/master' into codex/claude-transcript-enrichment
# Conflicts: # test/cli/query.test.js
2 parents e8f8439 + 2d2a23f commit ca5a355

6 files changed

Lines changed: 315 additions & 16 deletions

File tree

skills/collectivus-query/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Use `ctvs query` to inspect local Collectivus recordings. It reads local JSONL r
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.
19-
4. Prefer structured output for analysis: use `--format json` for follow-up reasoning, `--format markdown` when showing a table to the user, and `--limit` to keep output bounded.
19+
4. Prefer structured output for analysis: use `--format json` for follow-up reasoning and `--format markdown` when showing a table to the user. Query output is hard-capped at 100 rows (`--limit` defaults to 100 and cannot exceed 100), so use filters, `COUNT(*)`, aggregations, `ORDER BY`, and `OFFSET`/narrower predicates when you need more than the first page. For random samples, use `ORDER BY RANDOM() LIMIT n`; top-level random ordering is reservoir-sampled.
2020
5. Use high-level query commands before custom SQL. Switch to `ctvs query sql` only when the built-in commands cannot answer the question.
2121
6. For unfamiliar SQL tables, run `ctvs query schema <table> --format json` before querying. It works for built-in recording tables and tables registered with `ctvs collect`.
2222

@@ -116,6 +116,7 @@ Use `JSON_VALUE(<col>, '$.path')` to extract scalars from the `attributes` / `st
116116
- Do not paste `--config` into every command by habit. Use it when discovery shows the service is not using `~/.hyp/collectivus.json`.
117117
- Do not read arbitrary Parquet or Iceberg files directly for `ctvs query sql`; the CLI resolves SQL table names and injects only known query tables.
118118
- Keep SQL read-only and use only query tables from `ctvs query catalog`: built-ins (`logs`, `traces`, `metrics`, `proxy_messages`, `gascity_messages`) and registered collection tables.
119+
- `ctvs query sql` never returns more than 100 rows, even if the SQL text asks for a larger top-level `LIMIT`. Treat table-shaped results as samples unless the query is an aggregate/count that proves completeness.
119120
- Use UTC dates with `--date YYYY-MM-DD`; repeat `--date` when the user wants a union across multiple date partitions.
120121
- Use `--service`, `--gateway-id`, `--from`, `--to`, or `--since` to narrow broad investigations.
121122

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Commands default to `~/.hyp/collectivus.json`. If the running gateway or OTEL co
3535
- `--date <YYYY-MM-DD>`: Restrict to one UTC date partition. Repeat it to query or refresh multiple days.
3636
- `--gateway-id <id>`: Restrict to one gateway id.
3737
- `--service <name>`: Restrict `serviceName` for logs, traces, and metrics.
38-
- `--limit <n>`: Maximum rows to render. Default `100`, maximum `1000`.
38+
- `--limit <n>`: Maximum rows to return. Default `100`, maximum `100`.
3939
- `--format <fmt>`: `table`, `json`, `jsonl`, or `markdown`.
4040
- `--refresh <mode>`: `never` or `always`. Default `never`.
4141
- `--all`: Refresh all matching source files for `ctvs query refresh`.
@@ -140,3 +140,7 @@ ctvs query sql "select model, sum(cast(JSON_VALUE(attributes, '\$.usage.input_to
140140
```
141141

142142
SQL must be a read-only `select` over known query tables. Table names are resolved from the SQL AST and may be built-ins (`logs`, `traces`, `metrics`, `proxy_messages`, `gascity_messages`) or registered collection tables from `ctvs query catalog`.
143+
144+
`ctvs query sql` hard-caps top-level result sets at 100 rows. If the SQL omits a top-level `LIMIT`, the CLI applies `LIMIT 100`; if the SQL asks for a larger top-level limit, the CLI clamps it to 100. Use aggregates/counts for complete summaries, or add filters and `OFFSET` to page through wider table-shaped results.
145+
146+
Top-level `ORDER BY RANDOM() LIMIT n` uses reservoir sampling instead of sorting the full result set. It is still capped at 100 rows by the normal top-level limit rules.

src/cli/query.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Shared options:
7171
--date <YYYY-MM-DD> Restrict to one UTC date partition; repeat for multiple days
7272
--gateway-id <id> Restrict to one gateway id
7373
--service <name> Restrict serviceName for OTLP datasets
74-
--limit <n> Max rows to render (default: 100, max: 1000)
74+
--limit <n> Max rows to return (default/max: 100)
7575
--format <fmt> table, json, jsonl, markdown
7676
--refresh <mode> never or always (default: never).
7777
Stale partitions query with a stderr warning;
@@ -82,7 +82,7 @@ Shared options:
8282
--help, -h Show this help`
8383

8484
const DEFAULT_LIMIT = 100
85-
const MAX_LIMIT = 1000
85+
const MAX_LIMIT = 100
8686
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
8787

8888
/**

src/query/random-sample.js

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { executeSql, parseSql } from 'squirreling'
2+
3+
/**
4+
* @import { AsyncDataSource, AsyncRow, QueryResults, SqlPrimitive, Statement } from 'squirreling'
5+
*/
6+
7+
/**
8+
* @typedef {Record<string, SqlPrimitive>[]} SqlRows
9+
*/
10+
11+
/**
12+
* @typedef {{
13+
* limit: number,
14+
* query: Statement,
15+
* }} RandomSamplePlan
16+
*/
17+
18+
/**
19+
* @param {Statement} statement
20+
* @returns {Statement}
21+
*/
22+
function cloneStatement(statement) {
23+
return JSON.parse(JSON.stringify(statement))
24+
}
25+
26+
/**
27+
* @param {Statement} statement
28+
* @returns {(import('squirreling').SelectStatement & { orderBy: Array<{ expr?: { type?: string, funcName?: string } }> }) | undefined}
29+
*/
30+
function topLevelSelect(statement) {
31+
if (statement.type === 'select') return statement
32+
if (statement.type === 'with') return topLevelSelect(statement.query)
33+
return undefined
34+
}
35+
36+
/**
37+
* @param {ReturnType<typeof topLevelSelect>} select
38+
* @returns {boolean}
39+
*/
40+
function hasRandomOrder(select) {
41+
if (!select || select.orderBy.length !== 1) return false
42+
const expr = select.orderBy[0]?.expr
43+
return expr?.type === 'function' && expr.funcName?.toUpperCase() === 'RANDOM'
44+
}
45+
46+
/**
47+
* @param {string | Statement} query
48+
* @returns {RandomSamplePlan | undefined}
49+
*/
50+
export function getRandomSamplePlan(query) {
51+
const statement = typeof query === 'string' ? parseSql({ query }) : query
52+
const select = topLevelSelect(statement)
53+
if (!hasRandomOrder(select) || select?.limit === undefined || select.limit <= 0 || select.offset !== undefined) {
54+
return undefined
55+
}
56+
57+
const cloned = cloneStatement(statement)
58+
const clonedSelect = topLevelSelect(cloned)
59+
if (!clonedSelect) return undefined
60+
clonedSelect.orderBy = []
61+
clonedSelect.limit = undefined
62+
63+
return {
64+
limit: select.limit,
65+
query: cloned,
66+
}
67+
}
68+
69+
/**
70+
* @param {AsyncRow} row
71+
* @returns {AsyncRow}
72+
*/
73+
function memoizeAsyncRow(row) {
74+
/** @type {Map<string, Promise<SqlPrimitive>>} */
75+
const cellCache = new Map()
76+
return {
77+
...row,
78+
cells: Object.fromEntries(Object.entries(row.cells).map(([column, accessor]) => [column, () => {
79+
if (!cellCache.has(column)) cellCache.set(column, accessor())
80+
return cellCache.get(column)
81+
}])),
82+
}
83+
}
84+
85+
/**
86+
* @param {AsyncRow[]} rows
87+
*/
88+
function shuffleRows(rows) {
89+
for (let i = rows.length - 1; i > 0; i--) {
90+
const j = Math.floor(Math.random() * (i + 1))
91+
;[rows[i], rows[j]] = [rows[j], rows[i]]
92+
}
93+
}
94+
95+
/**
96+
* @param {{
97+
* tables: Record<string, AsyncDataSource | SqlRows>,
98+
* query: string | Statement,
99+
* plan?: RandomSamplePlan,
100+
* }} args
101+
* @returns {AsyncGenerator<AsyncRow>}
102+
*/
103+
async function* executeRandomSampleRows(args) {
104+
const { plan } = args
105+
if (!plan) {
106+
yield* executeSql({ tables: args.tables, query: args.query }).rows()
107+
return
108+
}
109+
110+
/** @type {AsyncRow[]} */
111+
const sample = []
112+
let seen = 0
113+
for await (const row of executeSql({ tables: args.tables, query: plan.query }).rows()) {
114+
seen++
115+
if (sample.length < plan.limit) {
116+
sample.push(row)
117+
continue
118+
}
119+
120+
const replacementIndex = Math.floor(Math.random() * seen)
121+
if (replacementIndex < plan.limit) {
122+
sample[replacementIndex] = row
123+
}
124+
}
125+
126+
shuffleRows(sample)
127+
for (const row of sample.map(memoizeAsyncRow)) {
128+
yield row
129+
}
130+
}
131+
132+
/**
133+
* Execute SQL, replacing top-level `ORDER BY RANDOM() LIMIT n` with reservoir
134+
* sampling so the executor does not need to materialize and sort every row.
135+
*
136+
* @param {{
137+
* tables: Record<string, AsyncDataSource | SqlRows>,
138+
* query: string | Statement,
139+
* }} params
140+
* @returns {QueryResults}
141+
*/
142+
export function executeSqlWithRandomSample(params) {
143+
const plan = getRandomSamplePlan(params.query)
144+
const baseResults = executeSql({
145+
tables: params.tables,
146+
query: plan?.query ?? params.query,
147+
})
148+
149+
return {
150+
columns: baseResults.columns,
151+
rows: () => executeRandomSampleRows({ ...params, plan }),
152+
numRows: baseResults.numRows,
153+
maxRows: baseResults.maxRows,
154+
}
155+
}

src/query/sql.js

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import fs from 'node:fs'
2-
import { asyncRow, collect, executeSql, extractTables, parseSql } from 'squirreling'
2+
import { asyncRow, collect, extractTables, parseSql } from 'squirreling'
33
import { parquetReadObjects } from 'hyparquet'
44
import { compressors } from 'hyparquet-compressors'
55
import { icebergDataSource, loadLatestFileCatalogMetadata } from 'icebird'
@@ -19,19 +19,20 @@ import {
1919
import { readCacheCursor } from './iceberg/cursor.js'
2020
import { createLocalIcebergIO } from './iceberg/resolver.js'
2121
import { queryCacheTableExists } from './iceberg/store.js'
22+
import { executeSqlWithRandomSample } from './random-sample.js'
2223

2324
/**
24-
* @import { AsyncDataSource, AsyncRow, ScanOptions, ScanResults, Statement } from 'squirreling'
25+
* @import { AsyncDataSource, AsyncRow, ScanOptions, ScanResults, SelectStatement, SetOperationStatement, Statement } from 'squirreling'
2526
* @import { CachePartition, QueryDataset, QueryPaths, QueryResultSet, QueryScope, ResolvedQueryTableInfo, ResolvedQueryTables } from './types.js'
2627
* @import { Lister, Resolver, TableMetadata } from 'icebird/src/types.js'
2728
*/
2829

2930
/**
3031
* @param {string} sql
31-
* @param {number} defaultLimit
32+
* @param {number} rowLimit
3233
* @returns {{ statement: Statement, tableNames: string[] }}
3334
*/
34-
export function prepareReadOnlySql(sql, defaultLimit) {
35+
export function prepareReadOnlySql(sql, rowLimit) {
3536
const trimmed = sql.trim()
3637
if (trimmed.length === 0) throw new Error('SQL query is required')
3738
/** @type {Statement} */
@@ -42,7 +43,7 @@ export function prepareReadOnlySql(sql, defaultLimit) {
4243
throw new Error(`SQL must be a single read-only SELECT statement: ${formatError(err)}`)
4344
}
4445
const tableNames = uniqueStrings(extractTables(statement))
45-
applyDefaultLimit(statement, defaultLimit)
46+
applyResultLimit(statement, rowLimit)
4647
return { statement, tableNames }
4748
}
4849

@@ -62,7 +63,7 @@ export async function executeLogicalSql(args) {
6263
: args.datasets && args.datasets.length > 0
6364
? await buildTables(args.paths, { ...args.scope, datasets: args.datasets })
6465
: {}
65-
const results = executeSql({ tables, query: args.statement })
66+
const results = executeSqlWithRandomSample({ tables, query: args.statement })
6667
const rows = await collect(results)
6768
return { columns: results.columns, rows }
6869
}
@@ -347,9 +348,15 @@ async function* scanBuiltinIcebergRows(dataset, sourcesPromise, columns, scope,
347348
/** @type {Set<string>} */
348349
const seenRowIds = new Set()
349350
const sources = await sourcesPromise
351+
const innerLimit = builtinInnerScanLimit(scope, options, sources.length)
350352
for (const { partition, source } of sources) {
351353
if (options.signal?.aborted) return
352-
const scan = source.scan({ columns: innerColumns, where: options.where, signal: options.signal })
354+
const scan = source.scan({
355+
columns: innerColumns,
356+
where: options.where,
357+
...(innerLimit === undefined ? {} : { limit: innerLimit }),
358+
signal: options.signal,
359+
})
353360
for await (const sourceRow of scan.rows()) {
354361
if (options.signal?.aborted) return
355362
const row = await resolveAsyncRow(sourceRow)
@@ -380,13 +387,19 @@ async function* scanCollectionIcebergRows(table, sourcesPromise, columns, scope,
380387
/** @type {Set<string>} */
381388
const seenRowIds = new Set()
382389
const sources = await sourcesPromise
390+
const innerLimit = collectionInnerScanLimit(scope, options, sources.length)
383391
for (const { source, meta } of sources) {
384392
if (options.signal?.aborted) return
385393
const innerColumns = scanColumnsWithPrivateColumns(
386394
requestedColumns,
387395
collectionScopeColumns(meta, scope)
388396
)
389-
const scan = source.scan({ columns: innerColumns, where: options.where, signal: options.signal })
397+
const scan = source.scan({
398+
columns: innerColumns,
399+
where: options.where,
400+
...(innerLimit === undefined ? {} : { limit: innerLimit }),
401+
signal: options.signal,
402+
})
390403
for await (const sourceRow of scan.rows()) {
391404
if (options.signal?.aborted) return
392405
const row = await resolveAsyncRow(sourceRow)
@@ -524,6 +537,30 @@ function canUseCollectionRowCount(scope) {
524537
return !scope.service && !scope.date && !scope.dates && !scope.from && !scope.to
525538
}
526539

540+
/**
541+
* @param {QueryScope} scope
542+
* @param {ScanOptions} options
543+
* @param {number} sourceCount
544+
* @returns {number | undefined}
545+
*/
546+
function builtinInnerScanLimit(scope, options, sourceCount) {
547+
if (sourceCount !== 1 || options.limit === undefined || options.offset !== undefined || options.where) return undefined
548+
if (scope.service || scope.from || scope.to) return undefined
549+
return options.limit
550+
}
551+
552+
/**
553+
* @param {QueryScope} scope
554+
* @param {ScanOptions} options
555+
* @param {number} sourceCount
556+
* @returns {number | undefined}
557+
*/
558+
function collectionInnerScanLimit(scope, options, sourceCount) {
559+
if (sourceCount !== 1 || options.limit === undefined || options.offset !== undefined || options.where) return undefined
560+
if (scope.gatewayId || scope.service || scope.date || scope.dates || scope.from || scope.to) return undefined
561+
return options.limit
562+
}
563+
527564
/**
528565
* @param {Record<string, unknown>} row
529566
* @param {CachePartition} partition
@@ -706,9 +743,9 @@ function collectionColumns(partitions) {
706743
* @param {Statement} statement
707744
* @param {number} limit
708745
*/
709-
function applyDefaultLimit(statement, limit) {
746+
function applyResultLimit(statement, limit) {
710747
const target = topLevelStatement(statement)
711-
if (target && 'limit' in target && target.limit === undefined) {
748+
if (target && isLimitableStatement(target) && (target.limit === undefined || target.limit > limit)) {
712749
target.limit = limit
713750
}
714751
}
@@ -718,10 +755,18 @@ function applyDefaultLimit(statement, limit) {
718755
* @returns {Statement | undefined}
719756
*/
720757
function topLevelStatement(statement) {
721-
if (statement.type === 'with') return statement.query
758+
if (statement.type === 'with') return topLevelStatement(statement.query)
722759
return statement
723760
}
724761

762+
/**
763+
* @param {Statement} statement
764+
* @returns {statement is SelectStatement | SetOperationStatement}
765+
*/
766+
function isLimitableStatement(statement) {
767+
return statement.type === 'select' || statement.type === 'compound'
768+
}
769+
725770
/**
726771
* @param {unknown} err
727772
* @returns {string}

0 commit comments

Comments
 (0)