hasql-interpolate + record-dot migration: derived ENUM/CITEXT decoders#409
Conversation
Reorder OtelLogsAndSpans fields to match otelSpanColsSql so both row instances drop to one-liner anyclass derives. Add the previously-absent errors and message_size_bytes columns to the SELECT path (and errors to the INSERT path) so they round-trip instead of being silently defaulted to Nothing / 0 on read. errors switches to Maybe (AesonText Value) for consistency with body/events. Consolidates spanRecordByName onto the shared otelSpanColsSql snippet so it can't drift again. Net: ~110 lines of hand-written instance plumbing gone.
QueryCache.hs (hit_count) and Issues.hs (affected_requests, affected_clients) decode into Haskell Int (= int8) but the underlying columns are INTEGER (int4), so every dashboard fetch and every cache lookup was throwing UnexpectedColumnTypeStatementError under hasql. Cast on the SELECT rather than narrowing the record field — safer because the same field types are used as Int across the codebase.
apis.issues.seq_num is INT; cast to bigint in the issue-list SELECT so the generic DecodeRow doesn't blow up on int4-vs-int8. projects.query_library.query_type is an enum; cast to text in the three UNION branches of queryLibHistoryForUser so the generic decoder reads it as Text instead of a custom OID. Add migration 0096 to convert the four remaining VARCHAR(n) columns (users.first_name/last_name, projects.teams.name/handle) to TEXT. VARCHAR's only practical difference is the catalog OID, and that OID mismatch was crashing every team-list and member-list lookup under the new hasql decoders.
cabal.project imported a 3300-line in-repo stackage snapshot just to strip hs-opentelemetry-* pins so the source-repository-package override at 1.0.0.0 could win. cabal.project.freeze already covers all pinned versions including source-repo packages, so the vendored snapshot is redundant. Also fixes CI: the Dockerfile COPY glob did not include stackage-pins.config, so docker builds were failing with 'stackage-pins.config: does not exist'.
The sidenav active-class hyperscript matched only the link href prefix, so navigating to /p/<pid>/endpoints (rendered by the API Catalog handler) lost the highlight after htmx pushState. Add a data-match attribute carrying extra path prefixes; the API Catalog item now declares /endpoints as a secondary match.
These were accidentally committed in 3e6f8a2; they are local work-in-progress notes, not source. Keep working copies on disk.
Code Review — Telemetry derive row instancesOverviewSolid cleanup PR. The headline change — deriving Issues1. Deriving generic 2. Branches 1 and 3 cast -- branch 2 should be:
SELECT id, project_id, created_at, updated_at, user_id, query_type::text, ...3. All three refineWith :: (Text -> Maybe a) -> Text -> D.Value a
refineWith f ctx = D.refine (\t -> maybe (Left (ctx <> ": cannot parse " <> t)) Right (f t)) D.textThen each instance becomes a one-liner again, matching the previous density. 4. hourlyRows :: [(Text, UTCTime, Int)] <- Hasql.interp [HI.sql|... event_count::BIGINT ...|]The annotation says 5.
Minor nits
SummaryThe generic derivation and SQL type fixes are correct and the net line count reduction is substantial. Address the UNION cast inconsistency (bug-risk), add a safety net for the field-order coupling (correctness risk), and clean up the three minor points above before merging. |
This comment has been minimized.
This comment has been minimized.
Replace string-concat + rawSql glue with single multiline [HI.sql|...|]
quasiquotes using ^{sql} splices for fragments and #{val} for values.
Drop NeatInterpolation usage. Collapse countEndpointInbox default to
correct enp alias.
Code ReviewOverviewThis PR upgrades The net delta is a solid -3700 lines — a big win. Potential Bugs1. In -- BackgroundJobs.hs
jobStats :: [(Text, Int)] <- Hasql.interp [HI.sql|SELECT status, COUNT(*)::bigint ...|]
stuckJobs :: [Int] <- Hasql.interp [HI.sql|SELECT COUNT(*)::bigint ...|]
-- Telemetry.hs getUsageTotals
getUsageTotals :: ... -> Eff es (Int, Int64, Int, Int64)
(eC, eB) <- Hasql.interpOne [HI.sql| SELECT count(*)::bigint, ...|]
-- Telemetry.hs getProjectStatsForReport / getProjectStatsBySpanType
... -> Eff es [(Text, Int, Int)]
[HI.sql| ... COUNT(*)::bigint AS total_error_events, COUNT(*)::bigint ...|]In contrast, hourlyRows :: [(Text, UTCTime, Int64)] <- Hasql.interp [HI.sql|SELECT ..., event_count::BIGINT ...|]If 2. -- Before
let oid = show orderId' :: Text
EHasql.interpExecute [HI.sql|... WHERE order_id = #{oid}|]
-- After
EHasql.interpExecute [HI.sql|... WHERE order_id = #{orderId}|]
OR (sub_id IS NULL AND order_id = #{orderId})Please verify the schema type of 3. -- Before: returned count of inserted/updated member rows
pure n
-- After: returns count of @everyone teams updated
pure teamsUpdatedThe function signature Minor Issues4. -- Before: if null elements (which was `normalLogElements`), use `rawDataLogElements`
V.fromList $ if null elements then rawDataLogElements else elements
-- After
V.fromList $ if isRawDataLog || null normalLogElements then rawDataLogElements else normalLogElementsThe old code used the runtime-selected 5. -- Before: hand-rolled SELECT without errors/message_size_bytes (set to Nothing/0)
-- After: uses otelSpanColsSql which includes both columnsThis is correct and fixes a latent incompleteness. Just verify no caller was relying on Improvements Worth Noting
|
Code Review: Telemetry derive row instancesGreat direction overall — eliminating ~4400 lines of hand-written boilerplate (row decoders, Potential bugs
jobStats :: [(Text, Int)] <- Hasql.interp [HI.sql|… COUNT(*)::bigint …|]
stuckJobs :: [Int] <- Hasql.interp [HI.sql|… COUNT(*)::bigint …|]
normalElements =
catMaybes
[ …
, tag "db.system" "neutral" <$> dbSys -- plain text badge
, …
, dbBadge <$> dbSys -- vendor-coloured right badge
, …
]The old code produced only the vendor-coloured right badge (e.g.
tag "process" "neutral" <$>
(nonEmptyT (atMapText "process.executable.name" resM) <|>
("PID " <>) . show <$> atMapInt "process.pid" resM)If Minor
hourlyRows :: [(Text, UTCTime, Int64)] <- Hasql.interp [HI.sql|… event_count::BIGINT …|]
let volumeMap = HM.fromListWith (++) [(h, [(t, fromIntegral c :: Int)]) | (h, t, c) <- hourlyRows]Fetching Things done well
|
WrappedEnumSC now takes a leading `Maybe Symbol` pinning the backing PG
type. `'Nothing` keeps the old text-backed behaviour; `('Just "schema.name")`
swaps in `D.enum`/`E.enum` so hasql-interpolate's strict OID lookup resolves
real `CREATE TYPE … AS ENUM` columns through generic DecodeRow without
per-query `::text` casts. `HI.DecodeValue (CI Text)` switches to `D.citext`
to cover the citext extension type.
Updated qualified sites: Permissions, IssueType, AnomalyTypes,
AnomalyActions, QueryLibType, KeyKind. Everything else gets `'Nothing`.
Migrations:
- 0097 drops the `email` domain (now plain citext + format CHECK) and
widens `apis.log_patterns.baseline_samples` /
`projects.replay_sessions.event_file_count` to BIGINT.
- 0098 batches the rest of the int4 → int8 widening (issues affected_*,
llm_enhancement_version, seq_num; error_patterns occurrences/etc.;
query_monitors check_interval_mins/threshold/notification_count/…;
notification_rate_limit.count) plus `apis.issues.error_rate` → float8.
Code fixes surfaced by the strict decoders:
- `Issue` gains `cooldownUntil` / `lastNotifiedAt` (rows existed since
0075/0085) and the IssueL list SQL selects them.
- BackgroundJobs SafetyNet uses `Telemetry.otelSpanColsSql` (was missing
`errors` + `message_size_bytes`).
- consumeNotificationToken returns `count` directly (no int4 cast).
- make_interval call casts hours to `int` (only signature PG provides).
- Anomalies VM format_examples now `TEXT[]` (matches Haskell Vector Text).
- LogQueries bis/cnts and totalSessions widened to Int64.
- Parser alert select emits `count(*)::float8` for Double consumers.
- Projects.downgradeToFree / upgradeToPaid cast bigint params to text for
the TEXT `order_id` comparison.
- isInCooldown SELECTs `1::bigint` so the Int64 decoder matches.
Code ReviewOverviewThis PR migrates to hasql 1.10's new API (schema-qualified Bugs / Risks1. The doctest Consider adding a second doctest or a unit test that round-trips a known 2. -- before
errors :: Maybe AE.Value
-- after
errors :: Maybe (AesonText AE.Value)
3. cooldownUntil :: Maybe ZonedTime
lastNotifiedAt :: Maybe ZonedTime
-- ...
deriving anyclass (FromRow, HI.DecodeRow, HI.EncodeRow, NFData, ToRow)
Minor Issues4. Redundant #{i.issueType}::apis.issue_type
5. instance ... => HI.EncodeValue (WrappedEnumSC qualType prefix a) where
encodeValue = case maybeSymbolVal (Proxy @qualType) of
Just qn ->
let (sch, typ) = splitQualType qn -- called once per dictionary construction
in contramap ... (E.enum sch typ id)This runs 6. The old What's Good
|
PG rewrites the heap+TOAST+indexes once per ALTER TABLE statement regardless of how many ALTER COLUMN clauses it contains, so collapsing 23 statements into 4 (one per table) cuts the migration's I/O by ~6x. Notable on apis.error_patterns (8.9 GB TOAST) which goes from 9 rewrites to 1; reduces lock-hold window during prod deploy. Also drop the Int64->Int round-trip in fetchLogPatterns: hasql's DecodeValue Int instance already reads int8, so decoding directly into Int (and dropping the fromIntegral) matches buildHourlyBuckets's signature without the cast.
|
Code Review Overview Excellent PR overall. Migrates string-interpolated SQL (NeatInterpolation, raw concatenation) to proper parameterised hasql-interpolate quasi-quotes throughout, aligns Haskell int types with Postgres bigint columns, and lands the WrappedEnumSC shape change for OID-matched enum decoding. The reduction in boilerplate (pidText, V.fromList, intermediate let bindings, split deriving lines) is substantial and good. Bug: V.Vector Int32 vs BIGINT[] in fetchSessionSummary In LogQueries.hs the type annotation is unchanged: This PR changed the SQL to produce BIGINT[] for the three array columns (bi, c, e in the bkt CTE, changed from ::INT to ::BIGINT). The Haskell decoder for V.Vector Int32 uses D.arrayOf D.int4 and expects int4[]. The OID will not match int8[] and will throw UnexpectedColumnTypeStatementError at runtime on any non-empty result set. RawSessionRow was correctly updated (bis/cnts :: V.Vector Int64) but this separate query was missed. Fix: change the three trailing V.Vector Int32 to V.Vector Int64 in that tuple. Style: two separate deriving lines still on FieldTypes / FieldCategoryEnum In Catalog.hs these two types still have two separate deriving lines for WrappedEnumSC 'Nothing "FT". Every other type in the PR was collapsed to one deriving line. Combine them. Minor: runtime case on a type-level Maybe Symbol The new encodeValue/decodeValue instances for WrappedEnumSC dispatch via a runtime case on maybeSymbolVal. Since qualType is a type-level Symbol the branch is determined at compile time, but GHC only eliminates the case if it specialises the instance. A fully-static alternative is two overlapping instances (one for Nothing, one for KnownSymbol s => Just s) which eliminates the KnownMaybeSymbol class entirely. Low-urgency given -O2 but worth the small change for conciseness. Positive notes
|
…lTupleReponse getNormalTupleReponse returns only the first line as text and the second line as Maybe — so 'lines response' always yielded one element and every classifyIssueCriticality call short-circuited to 'Invalid response format from LLM'. Parse the raw LLM response with a prefix pattern and strip each line.
Code Review — PR #409: hasql-interpolate + WrappedEnumSC derived ENUM/CITEXT decodersOverviewSubstantial, well-motivated PR. The -4655 / +924 ratio is the headline: replacing hand-rolled string SQL, per-query Potential Bugs / Type Mismatches1. The SQL now emits -- src/Models/Apis/LogQueries.hs
rows :: [(Int64, Int64, Int64, Int64, Int64, Int64, Int64, Int64, V.Vector Int32, V.Vector Int32, V.Vector Int32)] <-Meanwhile the query now returns 2. -- src/Models/Apis/LogQueries.hs
hourlyRows :: [(Text, UTCTime, Int)] <- Hasql.interp [HI.sql|... event_count::BIGINT ...|]
3. Same issue in stuckJobs :: [Int] <- Hasql.interp [HI.sql|SELECT COUNT(*)::bigint ...|]Succinctness4. Two -- src/Pkg/SchemaLearning/Catalog.hs
deriving (AE.FromJSON, AE.ToJSON, FromField, ToField) via WrappedEnumSC 'Nothing "FT" FieldTypes
deriving (HI.DecodeValue, HI.EncodeValue) via WrappedEnumSC 'Nothing "FT" FieldTypesEvery other site in this PR merged them into one line. Should be: deriving (AE.FromJSON, AE.ToJSON, FromField, HI.DecodeValue, HI.EncodeValue, ToField) via WrappedEnumSC 'Nothing "FT" FieldTypesSame for 5. Unnecessary let dir = directionClauseSql outgoingM
in Hasql.interpExecute [HI.sql| ... AND archived_at IS NULL ^{dir} |]
Hasql.interpExecute [HI.sql| ... AND archived_at IS NULL ^{directionClauseSql outgoingM} |]6. (\(style, txt) -> tag "severity_text" style txt) . severityBadgeis just: uncurry (tag "severity_text") . severityBadgeDesign Notes (non-blocking)7. The old manual 8.
9. The Minor positives worth calling out
SummaryItems 1-3 are type mismatches that cause runtime failures when OID-based dispatch fires; likely only surface under load when arrays/counts exceed int4 range. Item 4 is a consistency nit against the rest of the PR. Items 5-6 are small succinctness misses. Everything else is non-blocking. |
Code ReviewGreat migration — the 🔴 Critical:
|
- archiveHosts/unarchiveHosts: drop let-bound dir, inline as ^{} splice
- normalLogElements: replace lambda-on-tuple with uncurry
Both types are written via named-column INSERTs (insertIssue, the reports insert); the positional encoders were never exercised. Dropping them removes a latent foot-gun now that Issue carries cooldown_until and last_notified_at columns whose positions don't match the named insertIssue column list.
Code ReviewThis is a well-structured migration PR. The core idea — encoding the backing PG type at the type level via
|
Code ReviewOverall: A well-motivated and comprehensive migration. The core design — surfacing the PG backing type through
|
Summary
Lands the in-progress hasql-interpolate migration on top of
master. Replaces ad-hoc per-query::textcasts with a single derived path:WrappedEnumSCnow carries the backing Postgres type, soDecodeRow/EncodeRowresolve realCREATE TYPE … AS ENUMandcitext/emailcolumns through hasql's OID lookup automatically — without changing the deriving site twice or sprinkling casts into the SQL.WrappedEnumSCshape changeWrappedEnumSC (qualType :: Maybe Symbol) (prefix :: Symbol) a.'Nothing→ unchanged behaviour, usesD.text/E.text(TEXT-backed enums).('Just \"schema.type_name\")→ usesD.enum/E.enum, so the column's domain-specific OID is matched at session init.HI.DecodeValue (CI Text)now usesD.citext(instead ofD.text).refineTextdeduplicates the three near-identicalWrappedEnum*DecodeValueinstances.Sites switched to qualified DB enums
Permissionsprojects.project_permissionsIssueTypeapis.issue_typeAnomalyTypesapis.anomaly_typeAnomalyActionsapis.anomaly_actionQueryLibTypeprojects.query_library_kindKeyKindapis.schema_key_kindEvery other
WrappedEnumSCderiving site got'Nothingprefixed (mechanical).Migrations
emaildomain (column becomes plaincitext+ format CHECK constraint), widenapis.log_patterns.baseline_samplesandprojects.replay_sessions.event_file_counttoBIGINT.INTcolumn whose Haskell field isInt(decoded viaD.int8):apis.issues.{affected_requests,affected_clients,llm_enhancement_version,seq_num},apis.issues.error_rate→DOUBLE PRECISION, allapis.error_patterns.occurrences_*/quiet_minutes/resolution_threshold_minutes/baseline_samples/notify_every_minutes/regression_count, allmonitors.query_monitors.{check_interval_mins,threshold_sustained_for_mins,renotify_interval_mins,stop_after_count,time_window_mins,notification_count},apis.notification_rate_limit.count.Code fixes surfaced by the strict decoders
IssuegainscooldownUntil/lastNotifiedAt(rows already existed since migrations 0075 / 0085); the IssueL list SQL selects them.BackgroundJobsSafetyNet reprocess query now reusesTelemetry.otelSpanColsSql(was missingerrors+message_size_bytes).consumeNotificationTokenreturnscountdirectly (was explicitly casting to int4).make_intervalcall casts hours toint(only signature PG provides).getAnomaliesVMformat_examplesplaceholder is'{}'::TEXT[](wasjsonb[]).LogQueries.bis/cntsandtotalSessionswidened toInt64.Pkg.Parseralert select emitscount(*)::float8(was::integer).Projects.downgradeToFree/upgradeToPaidcast bigint params to text for the TEXTorder_idcomparison.Models.Apis.Issues.isInCooldownSELECTs1::bigint(so the Int64 decoder matches).Test plan
make test-unit— 207 examples, 0 failures.make test-integration— 530 examples, 0 failures, 25 pending.