Multi-dimension audit of
crystal-pg(pgshard, v0.30.0,crystal-db ~> 0.14.0) toward a1.0tag. Dimensions: correctness, concurrency / fiber-safety, error model, perf/memory/CPU, PostgreSQL protocol completeness, public API / SemVer surface, test coverage + unaudited files.Headline findings (compile / wire-format / security) were verified by hand against the source. Citations are
file:line.ℹ️ Transparency: produced with Claude Opus 4.8 [1M] (multi-pass static review, 6 parallel agents), then the blockers were hand-verified.
The architecture is sound and the layering is the right one: a low-level wire layer (PQ::*) cleanly separated from the crystal-db driver (PG::*), correct binary decoders for the large majority of types, SCRAM-SHA-256 + channel binding support (rare and well done), logical replication (pgoutput), and a real round-trip spec suite against Postgres 14–18. The bones are good. What follows is the map of what stands between today and a 1.0 that can be promised.
| Tier | Theme | Headline |
|---|---|---|
| 0 | Blockers | 4 spots that pass crystal build but break when the path is exercised: Interval#to_spans → undefined methods; .size instead of .bytesize (non-ASCII startup/password); a pp debug print in prod; CI on main ≠ master (never fires). (A 5th finding — decode error poisoning a pooled connection — was retracted after a reproduction showed it does not occur; see 0.5.) |
| 1 | Security | verify-ca/verify-full accepted but VerifyMode::NONE (no cert/hostname verification); an over-permissive .pgpass is read anyway; PGSSLMODE/PGSSL* env vars ignored |
| 2 | Correctness & concurrency | RowDescription table_oid/column fields swapped + truncated; infinity/-infinity timestamps unhandled; Numeric#to_s truncation fragile; two array paths disagree on lower bounds; the LISTEN/NOTIFY loop shares the socket with no lock; @@location_cache not fiber-safe; error model has two disjoint trees |
| 3 | Protocol completeness | verify-* unimplemented; NegotiateProtocolVersion unhandled; no CancelRequest; missing types (time/timetz, inet/cidr/macaddr, money, ranges); replication: no slot creation, proto pinned to v1, ErrorFrame ignored |
| 4 | Perf / memory / CPU | decoder resolved by per-value OID hash instead of per-column; no real prepared statements (Parse on every exec); Array(Slice?) + per-column copy per row; to_f64 allocates a reverse+enum |
| 5 | API / SemVer | the whole PQ::* is :nodoc: but public in practice; PQ::Connection#soc + protocol I/O public; sslmode is a Symbol; Geo::Path inconsistent with its record siblings; PG::Error :nodoc: yet root of a hierarchy users must rescue; dead pre-Crystal-1.0 monkeypatches |
| 6 | Tests / CI / packaging | CI nix-only, single Crystal; frame.cr/query.cr/ext/openssl.cr/record.cr zero coverage; auth & escape-error specs disabled; depends on crystal-db, itself pre-1.0 |
src/pg/interval.cr:35-40 — to_spans returns {to_time_span, to_time_month_span}, but those methods don't exist; the real ones are to_span / to_month_span (lines 13, 31). Won't compile the moment any caller touches to_spans (no spec covers it, hence the silence).
Fix: {to_span, to_month_span} — note to_span requires approx_months when months != 0, so reconsider the signature. Add a spec.
src/pq/connection.cr:151 (acc + arg.size + 1) and :486 (password.size + 4 + 1) use the character count, not bytes. For any non-ASCII user/database/application_name/password, the length prefix undercounts → corrupted packet. Lines 497/505 (query.bytesize) are correct — hence the inconsistency.
Fix: arg.bytesize / password.bytesize.
src/pg/replication/error_frame.cr:11 — pp size: size writes to stderr on every replication error frame. Release-blocking (noise/professionalism).
Fix: delete the line. (See also copy_data.cr:23, an IO::Memory allocated but never used.)
.github/workflows/ci.yml:3-4 — trigger push: branches: [main], but the repo's default branch is master. Push CI never fires; only PRs / workflow_dispatch run.
Fix: master (or rename the branch).
Retracted after reproduction. The original claim was that a decode error in move_next/read (src/pg/result_set.cr) leaves a desynced wire buffer on a pooled connection. A reproduction (single-connection pool; mid-stream SQL error via 1/(10-i), type-mismatch decode error, and query_each mid-stream error) showed the connection is resynced and reused cleanly in every case:
- a server
ErrorResponsemid-stream is handled at the wire layer —handle_async_frames(ErrorResponse)(src/pq/connection.cr:220-221) doesexpect_frame Frame::ReadyForQuerybefore raisingPQError, so theReadyForQueryis consumed; - a decode error keeps
@end == false, sodo_close(result_set.cr:189-213) drains the remaining rows toReadyForQuery.
The rescue ex that sets @end = true (result_set.cr:62-65) is a defensive cleanup, but errors reaching it originate from an already-drained ErrorResponse, so it does not strand a ReadyForQuery. No fix required for connection safety. (The genuinely actionable residue is the error model, not connection state — see Tier 2: the generic raise "string" desync errors are untyped.)
src/pq/connection.cr:52 forces ctx.verify_mode = OpenSSL::SSL::VerifyMode::NONE with the comment "currently emulating sslmode require not verify_ca or verify_full", while conninfo.cr:168-171 accepts verify-ca/verify-full. A user asking for verify-full gets an unauthenticated TLS channel with no error — direct MITM exposure. The README (l178) even warns you're "not safe unless fully verifying". Requested by #240, #237, #231 (Azure / CockroachDB users blocked).
Fix: implement verification (VerifyMode::PEER + ca_certificates + hostname check) for those modes, or raise loudly if requested. The #1 security item for 1.0.
src/pq/pgpass.cr:14-18 — the permission check warns but reads the password anyway. libpq ignores a group/other-readable .pgpass. The spec (pgpass_spec.cr:55) only asserts the warning.
Fix: return nil (skip) when perms are too open, like libpq. The 0o7177 mask is also slightly off (libpq = & 0o077 != 0).
src/pq/conninfo.cr — none of these are read; an operator setting PGSSLMODE=require silently gets :prefer. PGCONNECT_TIMEOUT, PGOPTIONS also unhandled.
Fix: fall back default_sslmode to ENV["PGSSLMODE"]?, etc.
src/ext/openssl.cr (scram_signature) — the tls-server-end-point derivation reads correctly but has zero tests. The cert hash algorithm choice is exactly what needs a test vector before 1.0.
- RowDescription fields swapped + truncated —
src/pq/frame.cr:205-207,field.cr:5-7. Readscol_oidas i32 thentable_oidas i16. The protocol order is table_oid(i32), col_attr(i16), type_oid(i32)…: the bytes land correctly by coincidence (4+2), but the semantics are swapped andtable_oid(an i32 OID) is stored in an i16 → truncated. Fix: read table_oid(i32) then col_number(i16), retypeField. infinity/-infinityunhandled —src/pg/decoder.cr:420-430. Postgres sendsInt64::MAX/MIN(andInt32::MAX/MINfordate); the code doesJAN_1_2K + micros→ corrupt/overflowingTime. Fix: detect the sentinels.Numeric#to_sfractional truncation fragile —src/pg/numeric.cr:120-136. Mixesdscaleandstr.size(the significant length, not the 4-wide NBASE group width) to compute a slice index → wrong rendering when the last NBASE digit has leading zeros. Fix: build each group as a 4-char zero-padded string, concatenate, take exactlydscale.- Two array paths, two lower-bound behaviors —
src/pg/decoders/array_decoder.cr:40-51(pads syntheticnils indistinguishable from real NULLs) vs:86-93(ignores lbound). Inconsistent and both debatable for lbound≠1. Fix: unify, preserve lbound metadata. CharDecoderbyte-vs-char —src/pg/decoder.cr:108-114."char"(oid 18) is one byte, not a Unicode Char;[0]on byte 128–255 → replacement char,bytesize==0→IndexError. Fix: build theCharfrom the byte.escapedead fast-path —src/pg/escape_helper.cr:72.num_backslashes == num_quotes == 0parses as(Bool) == 0→ alwaysfalse: the escaping loop always runs (not an injection hole — the loop is correct — but a latent bug + perf cliff). Fix:== 0 && == 0.- Prepared = unprepared —
src/pg/connection.cr:26-32,statement.cr,pq/connection.cr:506. Both build the sameStatement; the Parse is always unnamed → nothing is prepared/reused server-side. Corroborated by #146 / #97. Document or implement (see 4.2). - Polygon/array: count not bounded —
src/pg/decoder.cr:302-303.Array.new(count)from a malformed frame → OOM. Fix: validatecountagainstbytesize. - Minor robustness —
versionindexesvers[0]/[1]unguarded (connection.cr:106,IndexErrorwith no dot);process_ssl_message(connection.cr:71-86); negativencolsunguarded (connection.cr:160).
- LISTEN/NOTIFY loop shares the socket with no lock —
src/pq/connection.cr:183-192. The@mutexonly guardsclose; nosend_*/read_*is synchronized. A query on the same connection while the async loop reads → two fibers inreadon one socket → framing corruption. Safe only becauseListenConnectionis dedicated — document as a 1.0 invariant and flagConnection#listenas not-for-general-use. @@location_cachenot fiber-safe —src/pg/connection.cr:68. Class-levelHashmutated bytime_zoneacross fibers/connections without a lock;clear_time_zone_cachecompounds it. Fix:Mutexor fiber-safe memoization.- Replication: keepalive vs reconnect —
src/pg/replication.cr. The read loop readssocketwithout thewrite_mutexwhile the keepalive fiber writes;reconnectreassigns@connunder the mutex while an in-flight keepalive captured the old socket.last_wal_byte_flushed/appliedand@closedare plainproperty/Boolread/written cross-fiber (OK in practice on 64-bit, formal race). Fix: capture@connonce under the lock insend_keepalive!;Atomic(Int64)/Atomic::Flag. @socreassigned (negotiate_ssl) read everywhere unguarded —connection.cr:38-62, TOCTOU withclose.
- Two disjoint exception trees —
src/pq/error.cr(PQ::PQError,PQ::ConnectionError<::Exception) vssrc/pg/error.cr(PG::RuntimeError < PG::Error < ::Exception). A server-side SQL error surfaces asPQ::PQError, sorescue PG::Errorwon't catch it. No common rescuable root. Fix: unify under one documentedPG::tree. PG::Erroris:nodoc:while it's the root users mustrescue— contradictory and SemVer-hostile.- Generic
raise "string"on protocol desync —statement.cr:27("expected RowDescription or NoData, got …") — raises a bareException, indistinguishable from any other and not under aPG/DBerror root. This is an error-model/typing gap (not a connection-poisoning bug — see retracted 0.5). Fix: raise a typedDB::Error/PG::RuntimeError. - Replication: frame loop swallows decode errors —
src/pq/connection.cr:204-206(rescue e; Log.error) → tight spin on a desynced stream,run_loopnever sees the error → no reconnect. Fix: propagate/break. - Replication:
ErrorFrameignored —src/pg/replication.cr:182-183no-op → server errors (slot conflict, missing publication) invisible. Fix: raise/surface. - Swallowed exceptions —
pg.cr:53-56,connection.cr:114-117(emptyrescueonclose, conventional but swallows everything).
Implemented (well): simple query, extended (Parse/Bind/Describe/Execute/Sync), COPY in/out (+ both for replication), auth OK/cleartext/MD5/SCRAM-SHA-256(-PLUS), SSL negotiation, async NotificationResponse/NoticeResponse/ParameterStatus, BackendKeyData parsed.
Missing:
verify-ca/verify-fullunimplemented (see 1.1).NegotiateProtocolVersion('v') unhandled →connectwould raise on a future 3.x protocol server.connection.cr:271-279.- No CancelRequest —
BackendKeyData(pid/secret) is parsed then discarded (connection.cr:271-279); no query cancellation (the standard async-safe mechanism). Fix: store pid/secret + acancelon a 2nd socket. - sslmode
allow/preferconflated —negotiate_sslalways tries SSL first; no non-SSL retry;sslnegotiation=direct(PG17) absent. - Multi-host / failover (
host=h1,h2),connect_timeout(→TCPSocket.newwith no timeout, a dead host hangs forever — a real availability bug),optionsunsupported (corroborated by #237/#231).conninfo.cr. - libpq key=value quoting (
password = 'a b',\escapes) unsupported; double parsing of URI query params (conninfo.cr:80vs84-88). - pgpass:
\:/\\escaping unhandled (pgpass.cr:22); socket-vs-localhost host match divergent. - Statement/portal Close, PortalSuspended (row-limited cursors), pipelining (#155), multiple result sets not exposed.
- GSSAPI/SSPI/Kerberos auth: enum values present but
raise unsupported. SCRAM SASLPrep missing (#181).
Missing types (PGValue / decoders): time/timetz (1083/1266) → fall through to bytea; inet/cidr/macaddr/macaddr8; money; bit/varbit; range types (#164); oidvector/int2vector. The unknown-OID fallback = silent ByteaDecoder (decoder.cr:504) hides gaps — prefer an explicit decoder that surfaces the OID. On the encoding side (param.cr), Time is formatted as text RFC3339 9-digit (Postgres truncates to µs) and there's no Time/Geo/Interval specialization inside arrays.
Replication: no CREATE_REPLICATION_SLOT (slot must be pre-created out of band); proto_version '1' pinned (no streaming/two-phase/column-list/row-filter, transaction_id fields commented out); binary 'true' hard-coded and inconsistent with the text tuple API; the doc references PG.listen_replication while the method is PG.connect_replication (replication.cr:12 vs pg.cr:34).
- H1 — decoder resolved by per-value OID hash —
src/pg/result_set.cr:173-179,decoder.cr:506.Decoders.from_oid(oid(index))=Hashlookup +not_nil!for every column of every row, though the mapping is fixed for the result set. Fix: precomputedecoders : Array(Decoder)per column when@fieldsis received. Biggest easy win. - H2 —
read_data_rowallocatesArray(Slice?)+ aSlicecopy per column/row —src/pq/connection.cr:159-172. Thecrystal-dbstreaming path avoids it, but this buffering path stays heavy. Fix: reuse a buffer / read straight into the decoder. - H3 — no real prepared statements —
statement.cr:10-32. Parse/Bind/Describe/Execute/Sync on every exec, SQL re-parsed server-side + args re-encoded; no named statement. Corroborated by #146/#97. Fix: cache named statements keyed by SQL (major win for prepared-heavy workloads). - H4 —
args.map { Param.encode }allocates an Array + String+to_sliceper value (TimeviaFormat.format). Consider binary encoding (Time/Int/Float) + a per-connection scratch buffer. Corroborated by #294 (binary bind args, ~44% fewer bytes/exec). - H7/
to_f64—NumericDecoderallocatesArray(Int16)per value (decoder.cr:495);to_f64(numeric.cr:65) allocates areverse+ enumerator;to_big_d/raBigIntper digit.versionre-parses on each call (connection.cr:106). - MED/LOW —
send_bind_messagetwo passes + small writes (OK, buffered);rows_affectedsplit(frame.cr:234);decode_arraydim_info[1..-1]slice per level. SSL throughput is also reported as a concern (#303).
PQ::*boundary to decide — allPQis:nodoc:but public in practice:PQ::Notification/Notice/Field/ConnInfo/PQErrorcome back through public callbacks.:nodoc:only hides docs. Decide what's public (at least Notice/Notification/Field/ConnInfo/errors) and mark the rest unstable, before freezing the wire layer at 1.0.PQ::Connection#soc+ protocol I/O public —connection.cr:16.getter soc+ dozens ofsend_*/read_*/write_*are public and let callers corrupt state. Fix:protected/private.sslmodeis aSymbol—conninfo.cr:27.:"verify-full"accepted but unverified (see 1.1). Fix: enum + enforce/reject.- Global mutable decoder registry —
decoder.cr:504-514,@@decoderswithout synchronization;register_decoderis the documented extension mechanism butDecodersis:nodoc:. Stabilize + document (registration at load-time only). PR #250 (citext) reworks exactly this. - Inconsistent value types —
Geo::Pathis a hand-written struct with no==/hash/to_samongrecordsiblings (geo.cr:8-18);Numericexposes a mutabledigits : Array(Int16)and has no==/hash(1.10 vs 1.100 unequal);Intervalgetters lack explicit types. Align before 1.0. - Exception hierarchy (see Tier 2) — unify, reconsider the
PQErrorname (double prefix). - Ad-hoc returns —
version→ non-evolvableNamedTuple;to_spans→Tuple. Consider structs. - SemVer-fragile monkeypatches —
src/ext/openssl.cr,IO::Sized#read_remaining=(result_set.cr:1-8, dead code: the comment says to remove it,crystal >= 1.0is required),DB::ResultSet#readreopens. Raising the Crystal floor (>= 1.1) and deleting the dead backport branches shrinks the fragile surface. - Nits —
"invalid paramater"(conninfo.cr:69),"prepared statment"(connection.cr:506),VERSIONduplicated in shard.yml/version.cr (manual sync).
Coverage table (source → tested):
| File | Tested | Note |
|---|---|---|
pg/driver,connection,decoder,decoders/array,geo,interval,numeric |
yes | real round-trip specs |
pg_ext/big_decimal,big_rational |
yes | via numeric_spec |
pq/conninfo,pgpass,param |
yes | no DB needed |
pg/escape_helper |
partial | 2 error cases commented out |
pg/replication/* |
partial | gated on wal_level=logical; proto ≥2 untested |
pg/copy_result,result_set,statement,error |
partial | COPY write / direct parse-bind uncovered |
pq/connection (auth) |
partial | specs disabled by default, clientcert pending |
ext/openssl.cr |
no | channel binding — 0 test |
pq/frame.cr |
no | wire parsing — 0 direct test |
pq/query.cr |
no | 0 direct test |
pg/record.cr |
no | macro/Reader never invoked |
pq/field,notice |
no/partial | indirect only |
CI / packaging:
- CI exists (
.github/workflows/ci.yml) but is nix-only (nix flake check). Postgres matrix 18/17/16/15/14 (good), but a single Crystal whileshard.ymlpromises>= 1.0, < 2.0. Add a Crystal matrix (min 1.0 + latest). - CI on
main≠master(see 0.4) → push CI is silent. CONTRIBUTING.md:12references a vanished.circleci/config.yml(stale docs).- Depends on
crystal-db ~> 0.14.0, itself pre-1.0 → a transitive blocker for a true 1.0. - Security-sensitive specs to re-enable: escape error-paths, auth (md5/SCRAM/cleartext/SSL cert). No concurrency tests (a pooled driver with a
Mutex), no large-data / disconnect-mid-row tests despite recent disconnection-handling commits.
Unaudited / zero-coverage hot-path files: pq/frame.cr, pq/query.cr, ext/openssl.cr, pg/record.cr.
Surveyed from will/crystal-pg (10 open PRs + 28 open issues). Many issues corroborate the findings above — these are not theoretical; users have been blocked for years.
| PR | Subject | Tier | Status / note |
|---|---|---|---|
| #304 | range + multirange (PG::Range(T)) |
3 | open, non-draft, recent — candidate to close the ranges gap |
| #300 | network types + ranges + cursor + Numeric#== |
3 + 5 | open; overlaps #304/#213; extract the non-range parts |
| #295 | binary params | 4 | draft; addresses H4; author has doubts (int4→int8 autocast); fixes #294 |
| #253 | pipelining | 3 | draft; closes #155 |
| #250 | citext + extension API + per-connection decoders |
5 + 3 | draft; addresses the "global mutable registry" finding |
| #213 | ranges (incomplete) | 3 | superseded by #304 |
| #132 | bigdecimal | 3/5 | 2018, likely obsolete (pg_ext/big_decimal.cr exists) |
| #121 | async notifications break RETURNING |
2 | 2017; real symptom of the shared async socket |
| #44 | "Use MemoryIO" | 4 | 2016, almost certainly obsolete |
3 PRs overlap on ranges (#304/#300/#213): only one should land. Tier 0 and Tier 1 have no PR — a zero-conflict contribution space.
| Tier | Issues | Corroboration |
|---|---|---|
| 1 — Security | #240 (verify-full, help wanted), #237, #231 (CockroachDB: verify-full + options param) |
✓ 1.1 — users blocked (Azure, CockroachLabs) |
| 2 — Correctness / error / recovery | #196 (pool never recycles broken connections — IO/disconnect, partly addressed by recent disconnection-handling commits; needs its own repro), #42 (reset, help wanted), #287 (Supabase/Supavisor), #263 (can't cast to JSON::Any), #252 (Time loses precision), #208 (message nil), #202 (query_all drops the first row), #150 (Array(Int) without DB type) |
✓ JSON decoder; ✓ Time encoding; ✓ array dispatch (NB: decode-error connection poisoning was retracted — see 0.5) |
| 3 — Completeness | #164 (ranges), #155 (pipelining), #281 (text-format results), #181 (SCRAM SASLPrep, filed by will), #237/#231 (options), #115 (composite type), #126 (BigDecimal) |
✓ missing types/protocol |
| 4 — Perf | #303 (sslmode=disable boosts throughput), #146 / #97 ("prepared statements bypass PARSE"), #294 (binary bind args) |
✓ H3 (prepared = unprepared); ✓ H4; TLS overhead |
| 6 — Tests / docs | #128 (lowercase SQL keywords in specs), #109 (hosted docs) | — |
Likely obsolete / config: #200 (RAISE EXCEPTION → on_notice), #192 (cleartext → documented), #111 (to_json), #144, #64 (old crystal-db mapping).
- #196 (broken pool on server restart): an IO/disconnect-recovery concern (distinct from the retracted 0.5). Recent commits ("Handle disconnections from the server") likely address much of it; it needs its own reproduction before any fix.
- Tier 1 + Tier 2: high value × no competing PR × demand proven by long-standing issues — the best entry point for contribution.
- For the umbrella issue, each tier can cite the issues it closes — a strong argument for the maintainer (who filed #181 themselves).
One parent (umbrella) issue + one child issue per tier (independently discussable), and a first PR on Tier 0 (the 5 blockers), each with a failing spec first, plus the CI fix so they can't recur.
Two questions before sending code:
- Spec framework for new specs — stdlib
spec(what the suite uses)? - For the completeness gaps (Tier 3) and security (Tier 1): implement (real TLS verification, missing types, cancel) or stop advertising (reject
verify-*, stop accepting what isn't handled) until they exist? Happy either way.