Files
cairnobs/docs/query-language-design.md
T
jcoffey-dev c920e0f2c4 Finish the Cairn OBS rename through services, docs, and assets
The rename commit before this one covered module paths and the obvious
user-facing strings; this is the rest of it -- the places where "sentry"
was a default value, a filename, or a picture rather than a word in a
sentence.

Defaults that changed: CLICKHOUSE_DATABASE (sentry -> cairnobs),
POSTGRES_DATABASE (sentry_metadata -> cairnobs_metadata), and
POSTGRES_USERNAME (sentry -> cairnobs), across api/alerting/ingest and
the enterprise binaries, plus the compose files and migrate scripts that
create those objects. These are *defaults*, so a deployment that sets
them explicitly is unaffected -- but any deployment relying on the old
defaults must have its environment updated before it picks this up, or
it will come up pointing at a database that doesn't exist.

Also: the light-mode logo variants (the dark ones existed alone, so the
landing page and sidebar rendered a dark mark on a light background),
regenerated favicons, and the docs/README/threat-model prose that still
said Sentry.
2026-08-22 16:12:08 -07:00

279 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Query language design
> **Status:** Design, approved 2026-08-14, not yet implemented (that's
> Task 3). This is the reference Task 3's implementation is built against
> — if implementation reveals this design is wrong somewhere, fix this
> doc in the same change, don't let them drift apart.
## Why this design, in one paragraph
Phases 01 shipped two disconnected, placeholder query paths: raw SQL
against ClickHouse, and free-text against Tantivy. Phase 2 needs one
query language that can express both filter/aggregation and free-text
search in a single query, without picking a winner between "give up
structured querying" and "give up full-text search." The approach below
does that by keeping parsing and execution strictly separate (a small
pipe-syntax grammar and an "opaque SQL" passthrough both compile to the
same IR) and by generalizing a mechanism Phase 1 already built and proved
works (Tantivy-prefilter → ClickHouse `IN (...)`) rather than inventing a
new cross-backend join strategy from scratch.
## Grammar
Pipe syntax, SPL-inspired, EBNF-ish:
```
query := (base_search | pipe_stage) ("|" pipe_stage)*
base_search := bool_expr // implicit filter/search, SPL convention
// omitted entirely when the query starts
// directly with a pipe-stage keyword (e.g.
// `stats count by host`, no leading filter,
// no leading "|") -- means match-everything.
// A field genuinely named "where"/"stats"/etc
// still parses as a filter (`where=foo`),
// disambiguated by comparator lookahead.
pipe_stage := "where" bool_expr
| "stats" agg_call ("," agg_call)* ["by" field ("," field)*]
| "sort" sort_field ("," sort_field)*
| "fields" field ("," field)*
| "head" [INT]
| "tail" [INT]
bool_expr := term (("and" | "or") term)*
term := field comparator value // structured filter -> ClickHouse
| "earliest" "=" time_expr // time range lower bound
| "latest" "=" time_expr // time range upper bound
| STRING | QUOTED_STRING // bare term -> free-text (Tantivy) on `message`
| "message" ":" QUOTED_STRING // explicit free-text (Tantivy phrase/wildcard syntax passed through)
comparator := "=" | "!=" | ">" | ">=" | "<" | "<="
agg_call := IDENT "(" [field] ")" ["as" IDENT] // count(), sum(field), avg(field), min(field), max(field)
sort_field := ["-" | "+"] field // "-" = desc (default), "+" = asc
time_expr := QUOTED_STRING // absolute RFC3339
| "-" INT ("s"|"m"|"h"|"d"|"w") // relative to query time, e.g. -1h, -7d
field := IDENT
```
### Worked examples
- `service=api | where status>=500 | stats count by host | sort -count`
`service=api` is the base filter (structured, top-level column);
`where status>=500` filters on `status`, which isn't a top-level
column (see field mapping below); `stats count by host` aggregates;
`sort -count` orders descending by the aggregate's implicit `count`
alias.
- `message:"connection refused" | stats count by host` — free-text
predicate feeding a ClickHouse aggregation. This is the case task 2
called "the hardest part" — see Execution below.
- `SELECT host, count(*) FROM logs GROUP BY host` — detected as SQL (see
Detection below), executed directly against ClickHouse.
## Parser: hand-written recursive descent, no new dependency
This grammar is small and stable — seven pipe-stage kinds, one
expression grammar for filters. A hand-written lexer + recursive-descent
parser beats a combinator library (e.g. `participle`) or a generator
(`goyacc`) here:
- **No new dependency.** Consistent with "ask before adding a new
external dependency, there's no case for one at this grammar size.
- **Error messages matter** for a user-facing query language in a way
they don't for most internal parsing — "expected `by` after `stats
count`, got `sort`" is easy to produce by hand, harder to get right
through a combinator or generated parser.
- Generator tooling (`goyacc`) adds a codegen build step disproportionate
to a grammar this size.
- This is the standard approach for small, real query DSLs at this
scope — not a novel choice.
## The SQL escape hatch: not parsed, wrapped as opaque IR
"Both syntaxes compile to the same IR" does not mean writing a SQL
parser — reimplementing ClickHouse's SQL dialect would be a large,
pointless undertaking when ClickHouse already parses its own SQL. A query
that starts with `SELECT` (case-insensitive — see Detection) skips the
pipe-syntax parser entirely and produces an IR value that wraps the raw
SQL string as an opaque passthrough node. Both syntaxes still flow
through the same `Plan` type and the same executor code path — that's
what "same IR" actually buys (one execution and testing surface), not a
shared abstract syntax tree. The existing SELECT-only / single-statement
/ keyword-blocklist validation (`api/internal/queryapi/validate.go`) is
reused unchanged as the guard before wrapping.
## IR (`Plan`)
```go
type Plan struct {
RawSQL string // set => everything else is ignored; opaque ClickHouse passthrough
TextSearch []TextPredicate // bare terms / message: clauses -> routed to Tantivy
Filters []FilterPredicate // structured comparisons -> ClickHouse WHERE
TimeRange *TimeRange
Aggregation *Aggregation // nil => raw rows, no GROUP BY
Sort []SortField
Fields []string // projection; empty => all columns
Limit *Limit // head/tail
}
type TextPredicate struct {
Query string // passed to Tantivy's query parser as-is
}
type FilterPredicate struct {
Field string
Op string // "=", "!=", ">", ">=", "<", "<="
Value string
}
type Aggregation struct {
Funcs []AggFunc // count/sum/avg/min/max, each with an optional field + alias
GroupBy []string
}
type AggFunc struct {
Func string
Field string // empty for count()
Alias string
}
type SortField struct {
Field string
Desc bool
}
type Limit struct {
N int
Tail bool // true = last N (by time), false = first N
}
type TimeRange struct {
From, To time.Time // relative expressions (-1h etc.) resolved to absolute at compile time
}
```
## Field mapping: top-level columns vs. `attributes`
`logs`' real columns (per `/storage`) are `timestamp, host, service,
severity, message, attributes, record_id`. Any field name in a query
that isn't one of those maps to `attributes['<field>']` — e.g.
`status>=500` compiles to a comparison against `attributes['status']`,
not a top-level column, since `status` isn't promoted (Phase 1's decision
not to promote anything without real usage data still holds). Because
`attributes` is `Map(String,String)`, every stored value is a string;
numeric comparators against a non-top-level field cast via
`toFloat64OrZero(attributes['field'])` when the compared value looks
numeric, otherwise compare as string. This is what makes `where
status>=500` work against the existing schema with no migration —
querying an unpromoted field is always slightly more expensive than a
top-level column, which is worth knowing, not hiding.
## Execution: routing between ClickHouse and Tantivy
The core mechanism already exists and is proven: Phase 1's `/search`
endpoint (`api/internal/queryapi/search.go`, `recordIDsQuery`) already
does exactly steps 12 below for text-only queries. Phase 2 generalizes
it into four cases:
1. **No `TextSearch` predicates** → pure ClickHouse path. Build one SQL
statement directly from `Filters`/`TimeRange`/`Aggregation`/`Sort`/
`Fields`/`Limit`. The common case, and the fast path.
2. **`TextSearch` predicates, no `Aggregation`** → Phase 1's `/search`
behavior, generalized: Tantivy resolves matching `record_id`s, then
`SELECT ... WHERE record_id IN (...)` for the rows, with `Filters`/
`TimeRange`/`Sort`/`Fields`/`Limit` folded into that same statement.
3. **`TextSearch` predicates *and* `Aggregation`** — the genuinely new
case (`message:"connection refused" | stats count by host`): Tantivy
resolves matching `record_id`s as a *prefilter*, not a join, then one
ClickHouse statement does `WHERE record_id IN (...) AND <Filters>
GROUP BY <...>`. Aggregation always happens in ClickHouse; Tantivy
only ever narrows which rows are eligible before that.
4. **`RawSQL` set** → executed as-is against ClickHouse, no Tantivy
involvement regardless of what the SQL contains. The escape hatch is
opaque by design — no attempt to detect free-text intent inside raw
SQL.
### Known scaling limitation
Steps 2/3's `record_id IN (...)` approach breaks down if a text search
matches a large number of rows — the `IN` clause is a literal, quoted
UUID list embedded in the query string. Phase 2's mitigation: cap the
Tantivy prefilter at **5,000** results. Tantivy's `TopDocs` already
returns most-relevant-first, so the cap keeps the *best* matches rather
than an arbitrary truncation, but it's a real limitation on result
completeness for very broad text searches combined with aggregation.
Documented in `/docs/query-language-reference.md`, not silently
swallowed.
This number isn't a first-principles estimate — running the Phase 2
benchmark against a real 1M-row dataset (`/docs/phase-2-runbook.md`)
caught the original 10,000 cap failing outright: 10,000 quoted UUIDs
(~39 bytes each) produces a ~390KB query string, which exceeds
ClickHouse's *default* `max_query_size` (262144 bytes / 256KiB) and
fails with a syntax error rather than degrading gracefully — a much
lower ceiling than "multi-million-entry" suggested before anyone had
actually tried it. 5,000 UUIDs (~195KB) stays safely under that default
with headroom. The real long-term fix (streaming `record_id` batches,
ClickHouse-side text indexing, a different join strategy, or simply
raising `max_query_size` server-side with matching memory sizing) is
explicitly future work, out of scope for Phase 2.
### Post-Phase-2 fix: `earliest=`/`latest=` never actually worked against live ClickHouse
Found during Phase 3's dashboard time-range picker work (the first thing
to run a relative `earliest=`/`latest=` query against real ClickHouse
end-to-end — none of Phase 2's own runbook queries or unit tests
happened to exercise it): `executor/sql.go` formatted `TimeRange` bounds
with `time.RFC3339Nano` (e.g. `2026-08-12T20:17:40.223505479Z`), which
ClickHouse's *implicit* string→`DateTime64` cast for a column-vs-literal
comparison rejects outright — `code: 53, Cannot convert string ... to
type DateTime64(9, 'UTC')`. ClickHouse's implicit cast is strict and
wants `'YYYY-MM-DD HH:MM:SS[.fractional]'` (space-separated, no `T`/`Z`);
the lenient ISO-8601-accepting `parseDateTimeBestEffort` is a different,
explicitly-invoked function, not what a plain `WHERE timestamp >= '...'`
comparison uses. Fixed by `formatClickHouseDateTime64` in `sql.go`. The
Phase 2 unit test that covered this (`TestBuildSQLTimeRange`) only
asserted the generated SQL *string*, against a fake `SQLRunner` — it
never caught this because nothing in that test actually asked real
ClickHouse whether the SQL was valid. Left here as a pointed reminder of
why this project's "actually run it" discipline exists: a passing test
suite and a working feature are not the same claim.
## Where this lives: `api/internal/querylang/`
Not a new top-level component. This subsystem always executes in-process
within `/api` — it doesn't run standalone, doesn't get its own Docker
image, and needs both connections `/api` already holds (the ClickHouse
driver, the search gRPC client). A new top-level directory would imply a
new deployable service, which this isn't.
```
api/internal/querylang/
lexer/ tokenizer
ast/ parsed pipe-syntax tree
parser/ tokens -> ast (recursive descent)
ir/ Plan and supporting types
planner/ ast -> Plan (field-mapping rule, SQL-passthrough detection)
executor/ Plan -> results (the four-case routing above)
```
Mirrors the existing `internal/queryapi`, `internal/searchclient`
convention already in `/api`. Each layer is independently testable per
task 3's requirement: "pipe syntax X compiles to IR Y" tests live in
`parser`/`planner` against fixture ASTs/Plans, no backend needed; "IR Y
executes correctly" tests live in `executor` against fakes for both the
ClickHouse and search-client interfaces, same pattern already used
throughout `/ingest` and `/api`.
## `/query` endpoint: auto-detect, with an explicit override
Detection: a request body's query starting with `SELECT`
(case-insensitive, same rule `validateSelectOnly` already applies) is
SQL; otherwise pipe syntax. Covers the overwhelming common case with no
extra field required. An optional `"language": "sql" | "spl"` field in
the request body overrides detection, for the rare case a pipe query
legitimately starts with the literal word "select" as a bare search
term. Auto-detect-with-override matches the shape of other
inference-with-explicit-override choices already made in this stack
(e.g. severity hints winning over parsed values when present) — good
default ergonomics, no silent ambiguity once a caller cares enough to be
explicit.