refactor(LogQueries): collapse wrappers onto jsonb_build_array(sub.*)#423
Conversation
executeArbitraryQuery previously wrapped each row with
SELECT (SELECT json_agg(x.value ORDER BY x.ordinality)::jsonb
FROM json_each(row_to_json(sub.*)) WITH ORDINALITY AS x) FROM (...) sub
to coerce arbitrary rows into a single jsonb-array column for hasql to decode
into Vector AE.Value.
That wrapper is portable to Postgres only. TimeFusion (DataFusion-backed)
ships neither json_each, row_to_json, WITH ORDINALITY, nor row-correlated
lateral UDTFs — the wrapper made TF unable to serve log-explorer queries
through this path. The wrapper also tripped PostgreSQL's own JSON parser on
TEXT columns containing NULL bytes or lone surrogates (see prior comment at
the RawSessionRow site), forcing typed-decoder workarounds.
Replaced with the equivalent SQL:
SELECT jsonb_build_array(c1,...,cN) FROM (<inner>) sub(c1,...,cN)
This works natively on both backends:
- Postgres: jsonb_build_array(VARIADIC any) -> jsonb is built-in.
- TimeFusion: json_build_array UDF now exposes a jsonb_build_array alias.
executeArbitraryQuery gains a column-count parameter; the two callers
(selectLogTable, fetchEventExamples) already know it via
queryComponents.toColNames / colsNoAsClause cols. Derived-column aliases
sub(c1,...,cN) decouple the wrapper from the inner SELECT's expression shapes
so we never have to introspect those.
Side effect: the JSON-parser hazard documented near RawSessionRow is also
gone for the Postgres path, since jsonb_build_array builds JSON from typed
values instead of re-parsing a Postgres-side text serialization.
executeSecuredQuery (Dashboards / AI raw-SQL path) is left on the legacy
wrapper for now — its callers don't have column-count info and never worked
against TimeFusion anyway.
Helper exposed as Pkg.Parser.wrapForRowExtraction for future callers that
want to wrap their own SQL.
- Drop unused 'wrapForRowExtraction' export from Pkg.Parser (YAGNI — exporting before a real caller exists expands the API surface and forces a future breaking rename). Inline the small alias-formula in executeArbitraryQuery; re-extract a HI.Sql-level helper if a second caller appears. - Update executeArbitraryQuery docstring: drop the dangling cross-reference, spell out the exact legacy expression we're replacing. - Guard 'colCount <= 0' — the previous code would emit 'SELECT jsonb_build_array() FROM (...) sub()' which is not valid SQL. Empty-projection inputs now short-circuit to V.empty. - Add TODO on executeSecuredQuery so the legacy wrapper there isn't overlooked once user/LLM-SQL column counts become derivable.
Critical bug flagged in PR review: selectLogTable was passing length toColNames as the wrapper's column count, but toColNames returns finalColumns (display columns only). When hasCountOver = True (aggregations, service-entry-spans target, summarize path), the inner SELECT also emits count(*) OVER() as _total_count — so PG sees N+1 output columns with only N sub() aliases and rejects with 'column alias list must be the same length as the number of output columns'. Pass length toColNames + (1 if hasCountOver else 0) so the alias list matches the actual emitted column count. Also bind colsNoAsClause cols once in fetchEventExamples (rawCols) so it's reused for both processedCols and the colCount.
…ents Per CLAUDE.md "compactness is a priority" and PR review feedback.
…ector first The earlier V.mapMaybe (V.fromList results) suggestion materializes the full vector before filtering. The original V.fromList $ mapMaybe results is cheaper: mapMaybe traverses the list and filters in one pass, then V.fromList packs once. Also stays consistent with executeSecuredQuery.
…Query onto jsonb_build_array(sub.*) Depends on TimeFusion PR #49 (WildcardFnArgExpander). With that rule in place, qualifier.* inside a function call expands to the qualifier's columns on both backends (native in PG; the new TF analyzer rule for TF). executeArbitraryQuery loses its Int column-count parameter and the +fromEnum hasCountOver arithmetic in selectLogTable — Postgres expansion already covers the count(*) OVER() column. executeSecuredQuery now reuses executeArbitraryQuery (it's just LIMIT-wrapping plus error mapping), which retires the legacy json_each/row_to_json wrapper and the TODO it carried. Net: -22 lines, one wrapper shape across all four call sites (selectLogTable, fetchEventExamples, Dashboards processVariable / processConstant, AI executeSqlQuery).
Code ReviewOverviewClean simplification — collapsing the `json_each(row_to_json(...)) WITH ORDINALITY` pattern into a single `jsonb_build_array(sub.*)` wrapper is a clear improvement, and reusing `executeArbitraryQuery` inside `executeSecuredQuery` removes real duplication. Issues1. `selectChildSpansAndLogs` is not migrated (line 362) This function still uses the old `json_build_array(r)::jsonb` pattern with explicitly-enumerated columns — the only remaining call site with the old approach. The PR description claims uniformity across all four call sites, but there are actually five active query sites. Even if full migration is not in scope here, the explicit `json_build_array(...)::jsonb` cast is redundant — `jsonb_build_array` is the native form and avoids the intermediate json→jsonb cast: ```haskell -- Minimal fix (drop the cast, align with new pattern) 2. Single-use `rawCols` binding in `fetchEventExamples` The intermediate `rawCols = colsNoAsClause cols` is used exactly once. Inline it: ```haskell 3. Raw `show limit` in SQL string in `executeSecuredQuery` ```haskell `limit` is a Haskell `Int` so there is no injection risk. But the rest of this module uses Hasql interpolation for literals (e.g. `#{skip}::BIGINT` in `fetchEventExamples`). Consider staying consistent: ```haskell Minor
SummaryCore change is correct and the direction is right. Actionable items: align `selectChildSpansAndLogs` to `jsonb_build_array` (eliminates the redundant json→jsonb cast), inline `rawCols`, and use Hasql interpolation for the `LIMIT` value. |
…apper to executeArbitraryQuery Per PR review: this function still built its own 'SELECT json_build_array($cols)::jsonb FROM ...' wrapper inline. Route it through executeArbitraryQuery so the single jsonb_build_array(sub.*) shape applies everywhere. Same wire output, no per-callsite SQL synthesis.
Code ReviewOverall: Clean simplification — collapsing four call-sites onto one wrapper shape is a good move, and Dead code —
|
PR review nits: - valueToVector was a duplicate of jsonArrayToVector, and selectChildSpansAndLogs no longer calls it after migrating to executeArbitraryQuery. Delete. - rawCols in fetchEventExamples was bound for two uses originally; after the hasCountOver +1 collapse it has a single use, so inline it.
Code ReviewOverviewClean consolidation that replaces the Issues / Suggestions1. Unnecessary
pure $ keepDescendantsOf ... seedSpanIds (V.toList results)At 2000 rows this is negligible, but if 2. -- before
processedCols = map (\c -> ...) $ colsNoAsClause cols
-- after
processedCols = map (\c -> ...) (colsNoAsClause cols)Both are valid Haskell, but the surrounding codebase (and this file) consistently uses 3. Silent discard in pure $ V.fromList $ mapMaybe jsonArrayToVector resultsA non- 4. let limited = "SELECT * FROM (" <> userQuery <> ") AS subq LIMIT " <> show limitWith Relude What's good
Test coverage / riskThe manual test against a dev TimeFusion cluster with TF PR #49 is still pending per the test plan. This is the main risk gate before merging. The PG path looks safe — |
Code Review (additive — building on prior comments)The core change is correct and the direction is right. A few points not covered or needing correction in the prior reviews: Correction:
|
Per PR review polish: - Widen keepDescendantsOf's row argument from [V.Vector AE.Value] to V.Vector (V.Vector AE.Value). selectChildSpansAndLogs now passes executeArbitraryQuery's result directly, eliminating the V.toList allocation at the call site. The internal HM.fromListWith body still materializes a list of rows once for the comprehension; that allocation is unavoidable without restructuring the children-map build. - Use bare 'fromList' inside executeArbitraryQuery — OverloadedLists is on globally, so the Vector return type resolves it unambiguously.
|
Code Review Overall: Clean, well-reasoned simplification. The unified jsonb_build_array(sub.*) wrapper removes real duplication and the PR description clearly explains the deployment dependency on TF #49. A few issues worth addressing. Bug / Inaccuracy RawSessionRow comment claims jsonb_build_array is safe for null bytes — this is wrong. PostgreSQL's jsonb type rejects null bytes in text values regardless of which jsonb function builds the value. The old comment was accurate: row_to_json/json_each suffered from it, and jsonb_build_array does too. The RawSessionRow typed-decode path is kept for perf reasons, not safety — the comment update overstates the guarantee and could mislead future readers. Suggested replacement: -- Mirrors the column order of the SELECT in 'fetchSessions'. Decoded
-- directly via hasql as a perf-oriented typed path; bypassing
-- 'executeArbitraryQuery' avoids double-decoding the full row as JSON.Inconsistency
Line 207 (new) uses bare Minor
childrenByParent = HM.fromListWith (<>) [(p, [r]) | r <- V.toList rows, Just p <- [pid' r]]The type signature now takes a childrenByParent = V.foldl' (\m r -> maybe m (\p -> HM.insertWith (<>) p [r] m) (pid' r)) HM.empty rowsNot blocking — just worth noting given the project's priority on conciseness and avoiding unnecessary conversions. Approved with suggestions The core refactor is correct and the net -22 lines is real. The deployment-order documentation in the PR description is exactly right. Address the RawSessionRow comment inaccuracy before merging; the fromList inconsistency and V.toList nit can be bundled in as a follow-up. |
… file consistency PR review correctly flagged that jsonb_build_array does NOT solve the NULL-byte / lone-surrogate hazard — Postgres' jsonb type rejects those bytes regardless of which jsonb function builds it. The RawSessionRow typed-decode path is safe because it bypasses jsonb entirely, not because the wrapper changed. Update comment to reflect reality. Also revert bare 'fromList' back to V.fromList — the rest of this file uses the V. qualifier consistently; introducing a single bare call was inconsistent. Other call sites can be migrated as a follow-up if the OverloadedLists direction is preferred.
ReviewThe previous review items have all been addressed — 1.
|
…dQuery Consistent with the rest of the module — all scalar literals go through HI.sql parameter interpolation rather than string concatenation. Drops one rawSql wrap as a bonus.
Code ReviewOverall: Clean, well-motivated refactor. The collapse onto a single Issues / Suggestions1. childrenByParent = HM.fromListWith (<>) [(p, [r]) | r <- V.toList rows, Just p <- [pid' r]]
childrenByParent = V.foldl' (\m r -> maybe m (\p -> HM.insertWith (<>) p [r] m) (pid' r)) HM.empty rowsAvoids the intermediate allocation and is consistent with the rest of the function already using 2. The resulting SQL is now: SELECT jsonb_build_array(sub.*) FROM (
SELECT * FROM (<userQuery>) AS subq -- added by executeSecuredQuery
LIMIT <n>
) sub -- added by executeArbitraryQueryThree nesting levels where two would do. The outer -- option A: pass limit into executeArbitraryQuery and let it LIMIT before wrapping
executeArbitraryQuery :: DB es => HI.Sql -> Maybe Int -> Eff es (V.Vector (V.Vector AE.Value))Or, simpler, keep the current two-arg split but build let limited = querySql <> [HI.sql| LIMIT #{limit}|]This only works if the caller-supplied query ends cleanly (no trailing semicolon, no trailing 3. Minor — rawSql "SELECT jsonb_build_array(sub.*) FROM (" <> querySql <> rawSql ") sub"Every other call site in the file uses 4. let limited = rawSql ("SELECT * FROM (" <> userQuery <> ") AS subq") <> [HI.sql| LIMIT #{limit}|]
Positives
|
Previous listToColNames/colsNoAsClause used T.splitOn "as", which mis-fires on any internal "as" substring (e.g. JSON-path literals like '$.id as ref', future lowercase cast(x as text)). New splitTrailingAlias uses breakOnEnd and validates the trailing token is an identifier, so internal occurrences are left untouched.
…ute to DLQ The previous onException handler swallowed dbInsert/TF write failures and returned ackIds for the whole batch, silently dropping data. Removing the catch lets Pkg.Queue's batch-failure handler decide: Hasql-transient → no ack (broker redelivers), anything else → DLQ via publishToDeadLetterQueue. TimefusionWriteFailureSpec guards against re-introducing the silent drop.
Code ReviewOverviewThis PR unifies all four
|
| Area | Verdict |
|---|---|
splitTrailingAlias correctness |
✅ fixes a real substring bug |
splitTrailingAlias case sensitivity |
stripAlias |
listToColNames / colsNoAsClause |
✅ cleaner, correct |
executeArbitraryQuery wrapper unification |
✅ solid simplification |
onException removal + test |
✅ correctness fix with regression coverage |
valueToVector dedup |
✅ |
The case-sensitivity gap in splitTrailingAlias is the only item I'd want resolved before merge — everything else is improvements.
|
Found 9 test failures on Blacksmith runners: Failures
|
… routing Replace exception-throwing in the OTLP ingestion path with explicit Either types so Pkg.Queue can route failures to the DLQ instead of silently dropping batches. Closes three silent-swallow bugs that all contributed to the 2026-06-10 40-min customer-data-loss outage. Surface area (Either-everywhere, never silent-drop): - bulkInsertOtelLogsAndSpansTF :: ... -> Eff es (Either WriteFailure (V.Vector (OtelLogsAndSpans, RowPoisonInfo))) - insertAndHandOff, processBatchPipeline, processList, processMessages, processReplayEvents — all return the same shape - Pkg.Queue.routeBatchOutcome — shared helper for both pubsubService and kafkaService New types in Models.Telemetry.Telemetry: - WriteFailure = These SomeException SomeException — which store(s) failed - BulkInsertResult — per-side bulk insert result with poison rows - RowPoisonInfo — per-row attribution (pgError, tfError) - PoisonMsg = (ackId, rawBytes, errorReason) — source poison for DLQ - retryHasqlWrite — generic bounded retry (symmetric for PG and TF) PG is now mandatory (was best-effort): the (Left ePg, Right _) branch used to log "PG_WRITE_FAILED_TF_OK" and continue, leaving dashboards short of rows. Now any per-store failure surfaces. Silent-swallows closed: - onException in processList that returned all ackIds on any throw - POISON_ROW_DROPPED in bulkInsertOtelLogsAndSpans (single-row bisect leaf) - queryProjectIdByKey's whenRightM Nothing on Hasql connection failure DLQ side-tracking headers (monoscope-pg-succeeded / monoscope-tf-succeeded / monoscope-write-failure) tell a future replay tool which side to rewrite. Decode/oversize failures (corrupt protobuf, oversized rrweb payload) now go through Pkg.Queue.publishToDeadLetterQueue before source-topic offsets are committed — pre-existing log-and-ack gap closed. Tests: TimefusionWriteFailureSpec exercises the contract end-to-end: write success, TF-only failure, mixed batches with corrupt messages, DLQ header construction, PG-mandatory enforcement via exception propagation.
Code ReviewOverall this is a well-structured refactor with a clear correctness win (silent-ack-on-failure → 🐛 Partial / unsafe
row = maybe (fst (fromJust tfEntry)) fst pgEntryThe invariant (if row = fst $ fromJust (pgEntry <|> tfEntry)Or restructure with Partial pattern in firstReason = case poison of (_, _, r) : _ -> rThis is structurally safe (the firstReason = maybe "" (\(_, _, r) -> r) (listToMaybe poison)✂️ Redundant code
rowPoisonInfo :: Maybe SomeException -> Maybe SomeException -> RowPoisonInfo
rowPoisonInfo = RowPoisonInfo -- identical to constructorDelete this function. Callers should use 🔧
|
Address actionable items from PR #423 review: - combinePoison: drop fromJust + () <$ pgMap pattern; use HM.intersectionWith / HM.difference (no partial functions, no Functor-on-HashMap-just-for-keys trick) - routeBatchOutcome.firstReason: listToMaybe over partial pattern - writeFailureDlqHeaders: const over \_ -> - Drop redundant rowPoisonInfo helper (constructor is already exported) - insertSystemLog: logAttention on Left so dropped system logs are visible during incident triage rather than only logged inside the inner write - TimefusionWriteFailureSpec: drop date from module comment hlint cleanup in newly-touched code: - processBatchPipeline: any (not . V.null) → all V.null (Hoist not) - Pkg.Queue: HM.singleton → one ProcessMessage's "pure $!" stays — the strictness forces the tuple to WHNF immediately, preventing thunk accumulation across forM. Added a localized {-# HLINT ignore "Redundant $!" #-} so hlint stops complaining.
Code Review: refactor(LogQueries): collapse wrappers onto jsonb_build_array(sub.*)Overview: This PR does two largely independent things — (1) simplifies the SQL row-extraction wrapper in 1.
|
Address follow-up review on the Either dual-write refactor: - RowPoisonInfo is now `These SomeException SomeException` (was a record with two Maybes). Eliminates the impossible (Nothing, Nothing) case that previously needed a defensive catch-all. combinePoison rewrites its three buckets as This / That / These directly. - Extract `poisonReason :: RowPoisonInfo -> Text` to Telemetry so both OtlpServer.dualWriteWithPoisonMapping and ProcessMessage's legacy path produce identical "row insert failed (pg-only / tf-only / both): …" reasons. ProcessMessage no longer emits a generic "row insert failed". - Drop stale TODO in TimefusionWriteFailureSpec — the surrounding test now actually asserts the decode-failure → DLQ routing, so the gap noted in the comment is resolved by this PR. - Comment retryHasqlWrite's TuplesOk-mempty return so callers watching rowsInserted understand "0" means "wire-mismatch swallowed, count unknown" rather than "no rows inserted". - Restore per-batch db_insert_ms timing as a separate trace line in processBatchPipeline — outer processList still logs total batch duration; dashboards depending on the inner DB slice keep their metric. - processMessages: empty-rMsgs branch returns `[]` directly instead of `map (\(a,_,_)->a) rMsgs` over an empty list.
Applied from third-round /simplify review: - poisonReason now uses displayException (matches the toText.displayException convention used elsewhere) and truncates each side to 2 KiB, so a nested exception with a multi-MB toString can't blow past broker message limits. Full stacks remain in logAttention emitted at the write site. - combinePoison short-circuits when one or both sides have zero poison rows. The success path (the common case) used to allocate two HashMaps + three Monoid merges per batch even when there was nothing to merge — now it returns V.empty / a single V.map. - Use Data.Bifunctor.second instead of \(r, e) -> (r, This e) at the three remaining lambda sites in bulkInsertOtelLogsAndSpansTF. - Trim two verbose comments down to their load-bearing bits. Skipped: - Renaming RowPoisonInfo → RowWriteFailure (debatable; current name keeps it lexically grouped with PoisonMsg and combinePoison). - newtype-wrapping the SomeException pair (asymmetric with how SomeException is exposed everywhere else in this module). - Moving db_insert_ms onto Pkg.Metrics.timed (scope creep — would need a new ingestDbInsertHist counterpart to ingestDecodeHist/ingestWriteHist).
- toSnd/traverseToSnd, second/first from Bifunctor at lambda sites - point-free in tolerantLogger and SchemaCatalog batch retry - guards for icon/color in mkSlackLogPatternRateChangePayload - fromMaybe for translation fallback in Web.I18n - addDays (-1) instead of pred for cycle end-label - rename Data.Set alias Set -> S per project convention
Code Review — PR #423: collapse wrappers onto
|
Code Review — PR #423: collapse wrappers onto
|
…ll fns - dualWriteWithPoisonMapping: explicit length check before zipWith so a future change to stampOrPassthrough / mintOtelLogIds that drops or reorders records fails loud at the boundary instead of silently misaligning ackIds with record IDs. - Lift 'maxWriteAttempts = 10' to a top-level constant so PG and TF retry budgets move together; replace the three call-site literals. - combinePoison: 'HM.unions [both, pgOnly, tfOnly]' (the three maps have provably disjoint keys, so union reads more clearly than Monoid append) and a one-line note that result order is HashMap-iteration order — DLQ consumers process entries independently so order is irrelevant. - ProcessMessage: 'maybe V.empty snd mWrite' replaces a duplicate case-destructure of the same Maybe. Skipped: - newtype-wrapping WriteFailure and RowPoisonInfo to distinguish them at the type level — the prior round of review explicitly asked for the lighter 'type … = These …' aliases, and we use SomeException bare elsewhere. The footgun is real but a separate PR. - splitTrailingAlias case-insensitivity — pre-existing in the parent Pkg/Parser commit, not in this PR's scope.
Code ReviewOverviewThis PR refactors the dual-write pipeline to surface failures explicitly via Issues / Concerns1. Duplicate type aliases for the same type type WriteFailure = These SomeException SomeException -- batch-level
type RowPoisonInfo = These SomeException SomeException -- row-levelIdentical definitions; GHC won't prevent mixing them up. A newtype or even 2. Misplaced/duplicated docstring on The 3. The three-way HashMap join (pgOnly / tfOnly / both) is exactly what import Data.Semialign (align)
-- align :: (Align f) => f a -> f b -> f (These a b)Even if 4. Silent drop risk in , Just (ackId, raw) <- [HM.lookup r.id idToSource]If the lookup ever misses (the length-equality guard proves it can't today), the row is silently absent from both the ack list and the poison list — neither written nor DLQ'd. A 5. For unaliased columns the result is the same. Worth adding a doctest: -- >>> colsNoAsClause ["id", "CAST(x AS INT)"]
-- ["id","CAST(x AS INT)"]to guard against future regressions on the 6. No 7. The comment acknowledges this, but returning Minor style / cleanup (all good)
Test coverage
SummarySolid refactoring with a real correctness improvement — no more silent acks of DLQ-worthy messages, and |
Follow-up to the merged Either dual-write refactor (#423): - dualWriteWithPoisonMapping + processMessages: emit logAttention if any poison row fails the (recordId → sourceAckId) lookup. By construction the map covers all input records (insertAndHandOff receives the same vector the map is built from), but a future change to stamp/mint that breaks 1:1 ordering would otherwise silently drop poison rows from both the ack list and the DLQ batch. Logging the IDs makes the invariant break visible at runtime. - Move maxWriteAttempts above retryHasqlWrite so each function's docstring sits directly above its signature (was bumped into the next definition's haddock). - Drop unused `deriving stock (Generic)` from BulkInsertResult — no consumer derives JSON / NFData / etc. off it.
Code Review — PR #423: collapse wrappers onto
|
…projection wrap PR #423's `SELECT jsonb_build_array(sub.*) FROM (...) sub` works on TF (WildcardFnArgExpander analyzer rule) but breaks on PG: `sub.*` inside a function call passes the row as a single composite, yielding a 1-element jsonb array per row. Root cause of the "index out of bounds (7,1)" failures across log explorer, gRPC sessions aggregation, CLI events search, and trace-tree expansion. Internal callers (selectLogTable, selectChildSpansAndLogs, fetchEventExamples) know their projection lists — they now inline `SELECT jsonb_build_array(<bare-cols>) FROM ...` directly. Same wire shape on both backends, no analyzer-rule dependency, ~0.22ms / 200-row page measured. executeArbitraryQuery deleted. executeSecuredQuery (Dashboards widgets, Pkg.AI raw SQL — opaque projection, must reach TF) keeps the `sub.*` wrapper. Migration 0099 adds a PL/pgSQL `jsonb_build_array(record)` overload so PG dispatches the same shape; STABLE because row_to_json honors STABLE per-column output functions (e.g. timestamptz's TimeZone GUC). Also: splitTrailingAlias is case-insensitive (aggregations emit uppercase AS; jsonb_build_array args reject any AS); the duplicate `stripAlias` in applySectionToComponent collapses onto it.
Summary
Builds on top of PR #422, depends on TimeFusion PR #49 (
WildcardFnArgExpander).With qualifier-wildcard expansion working on both backends, the row-extraction wrapper collapses to a single one-line shape:
That lets us:
Intcolumn-count parameter fromexecuteArbitraryQuery.+ fromEnum queryComponents.hasCountOveraccounting inselectLogTable(PG's*expansion already covers the syntheticcount(*) OVER()column).colCount <= 0 → V.emptyguard (no parameter to guard against).json_each(row_to_json(*)) WITH ORDINALITYwrapper thatexecuteSecuredQuerystill carried, along with its TODO.executeSecuredQuerynow just LIMIT-wraps the user query and delegates toexecuteArbitraryQuery.Net: −22 lines, one wrapper shape across all four call sites (
selectLogTable,fetchEventExamples,Pages.Dashboards.processVariable/processConstant,Pkg.AI.executeSqlQuery).Order of operations
executeArbitraryQuerywithc1..cNaliases; safe under either TF version).Test plan
cabal build lib:monoscope— clean compile.jsonb_build_array(sub.*)is native in PG).