Running it¶
What happens between init and the first query, and what a query answers
when it cannot answer a Row: the check at startup, the stack a handler
holds, a second database, the prepared statements you did not ask for, the
log line that shows them, and the nine errors.
When a Row and its table disagree¶
db.checking(.{ .tables = &.{ User, Order } });
Each Row is compared against the table it names, once, while the server
starts. A column that is missing, or is text where the struct says i32,
stops startup with a line naming it — instead of becoming a 500 at three in
the morning on whichever request reached it first.
A table that is not there at all is one line rather than one per column, because the mistake is one mistake:
nilo_sql: nilo: User reads table "users", and the database has no table by
that name
which is usually a migration that has not run.
Set .schema_mismatch_is_fatal = false to log and carry on.
A Db that never had checking called on it says so at startup, at
warn, once: the Rows will be checked by the first request that reads
them, which is later than anybody wanted. It is one line and it is not a
failure — a program with a Db and no Rows is a perfectly good program.
Say .unchecked = true in the options when that is what was meant, and
the line goes away
(ADR 192):
var scratch = sql.Db.init(gpa, url, .{ .unchecked = true });
The two used to look the same, and the second was the one that reached
production: a Row disagreeing with its table on a Db nobody had thought
about checking.
The arena is cheaper than the stack¶
Worth knowing before you write a handler that needs a scratch buffer, because it is the opposite of the usual Zig advice:
fn report(db: *sql.Db, c: *nilo.Ctx) ![]const u8 {
var buf: [64 * 1024]u8 = undefined; // ✗ per connection
const buf = try c.arena().alloc(u8, 64 * 1024); // ✓ per request
A connection waiting for its next request is a suspended fiber, and a suspended fiber holds its stack at the deepest point it ever reached. So a 64 KiB stack buffer is 64 KiB held for as long as that connection stays open — measured one byte per byte, from 8 KiB to 128 KiB (ADR 062). The arena is reset after every request.
It applies to the database path too, and that is where the number came from: a route that reads one row and answers JSON holds 17,022 bytes per idle connection against 8,749 for one that returns a constant. Most of the difference is how deep the driver's protocol code goes, and none of it is something the query did.
A second database¶
The Service registry is keyed by type, so *sql.Db is the database and a
second one had nowhere to live. sql.Named gives it a type of its own:
const Replica = sql.Named("replica");
fn listing(rdb: *Replica, c: *nilo.Ctx) ![]Product { // may be stale
return rdb.select(Product, c, .{ .order = .{ .name = .asc } });
}
fn buy(db: *sql.Db, c: *nilo.Ctx) !Order { // must not be
return db.insert(Order, c, .{ .user_id = 1, .total = 4200, .status = "new" });
}
Two names are two types and two types are two services, so both are
app.provided and both are checked at listen() like any other. Which
pool a statement takes is in the argument list, which is where you can see
it without leaving the line.
Nothing routes anything, and that is deliberate. A reader that sent writes to
the primary and reads to a replica would need health checking, lag awareness
and read-after-write safety — three background tasks this module does not
have, and the last one fails silently
(ADR 054). Writing
*Replica in a signature is you saying "stale is fine here", once, on
purpose.
It is not only for replicas: a reporting warehouse, a second tenant, a
database somebody else owns. sql.Named("") is a compile error, because the
name is the whole mechanism.
There is no query cache and there will not be one. The speed case is the strong half — a round trip is 24 µs and the query inside it is 2 — but invalidation cannot be right from here, because this module sees only the writes that go through it. Hold the value in a Service of your own, where the rule for when it goes stale is a rule you know.
Statements are prepared, and you did nothing to ask for it¶
Every statement this module sends is settled while compiling, so there is a fixed set of them and each one is kept prepared on the connection it went down. The second time a connection sends it, Postgres skips Parse and Describe.
It is worth about 12 µs a query — 30% of a key lookup, 14% of a page with a sort and a range (ADR 051). A fixed saving, so the cheap queries a service runs most of are the ones it helps most. Nothing in your code changes.
db.raw is in it too. Its text is comptime, so its name is derived the same
way (ADR 051).
Turn it off behind pgbouncer in transaction mode.
var db = sql.Db.init(gpa, url, .{ .prepared = false });
A transaction-mode pooler hands out a different server connection per transaction, so a statement prepared on one is missing on the next. The failure is loud — Postgres says the prepared statement does not exist — which is why the default is the fast one rather than the safe one.
Seeing the statements a request sent¶
One line per request tells you a page is slow. What was slow in it is the
statements, and db.watching is how they are shown:
db.watching(sql.logging); // one debug line per statement
Set it before listen(). sql.logging writes the duration, the row count and
the text at debug level; anything narrower is a function of your own:
fn slowOnes(sent: sql.Sent) void {
if (sent.micros < 50_000) return;
std.log.warn("slow query: {d}us, {s}", .{ sent.micros, sent.sql });
}
db.watching(slowOnes), and nothing else changes. A sql.Sent carries the
statement, the name it is kept prepared under, how long the database took, how
many rows moved, and whether it failed. Not the
values it bound — those are the interesting half and they are also somebody's
password, so putting them in a log is a decision rather than a default
(ADR 108).
A statement that failed carries one thing more: sent.problem, which is what
the database said about refusing it.
fn whyItFailed(sent: sql.Sent) void {
const said = sent.problem orelse return;
std.log.warn("{s} [{s}] on {s}: {s}", .{
said.message, said.code, said.constraint, sent.sql,
});
}
message always says something. When the driver refused the statement before
it left the process — a value it will not bind — there is no server message, so
the Zig error's own name goes there instead. code is the SQLSTATE, 23505
for a duplicate key; severity, detail, hint and constraint are the rest
of what Postgres knew. Fields a database does not answer are empty rather than
null, because SQLite has no SQLSTATE and does not invent one
(ADR 117).
It lives in the request's arena, so keeping one past the request means copying
it. detail is usually the values that collided, which is worth knowing
before you log it. None of it ever reaches the client.
A Db nobody is watching pays one null test per statement, and a watched one
pays two clock reads at 15ns each.
Views, and the one thing a check cannot know¶
A Row can name a view or a materialized view instead of a table, and
everything works the same way — reading it, checking it, db.raw past it.
One half of the check is skipped there, and it has to be: Postgres does not
track NOT NULL through a view, so every column of one reads as nullable
whatever its source column was. Checking that would flag every non-optional
field of a Row over a view, so the column's type is compared and its
nullability is left alone
(ADR 050).
Columns the database fills in¶
An identity key, a sequence default and a generated column all work with
nothing said about them, because an insert names a subset of the Row's
columns and RETURNING is not optional:
const Auto = struct {
pub const nilo_table = .{ .name = "auto", .key = .id };
id: i64, // GENERATED ALWAYS AS IDENTITY
label: nilo.Str,
slug: ?nilo.Str, // GENERATED ALWAYS AS (label || '-x') STORED
};
const made = try db.insert(Auto, c, .{ .label = "alpha" });
// made.id is the database's, made.slug is "alpha-x"
A batch is the same: the arrays hold only the columns that were written. Note
that a generated column carries no NOT NULL unless one was written, so the
Row reads it as an optional.
A Row can say more about its table than its columns — a default, a unique, an
index and its predicate, a foreign key — and that is the subject of
Making the tables. What is past those words, a check
constraint you wrote yourself or a trigger, is written where you write the rest
of your DDL. The half that reaches a handler is already done either way: a
unique violation is error.AlreadyExists and a 409.
Errors¶
The module raises nine, and they read:
error.AlreadyExists |
a unique violation — 409 by default |
error.ForeignKeyViolated |
a row this statement names is not there, or a row it removes is still named by another. No default |
error.NotNullViolated |
a NOT NULL column was sent a null — 500 |
error.CheckViolated |
a CHECK said no |
error.ConstraintViolated |
whatever is left — an exclusion constraint, a RESTRICT |
error.Locked |
a .lock = .update_nowait found a row somebody else holds. No default |
error.Disconnected |
the database went away, or was never there |
error.TimedOut |
a statement ran past the tx.deadline you set |
error.QueryFailed |
anything else. The server's text is logged, never sent |
Only the first has a default answer, and that is on purpose. A duplicate email on a signup is a 409; the same code inside a background import is not an HTTP answer at all; on a table used to win a race it is the expected outcome. The module does not know which request it is inside, so it hands you an error that reads and lets you decide:
const made = db.insert(User, c, .{ .email = email, .name = name }) catch |err| switch (err) {
error.AlreadyExists => return nilo.fail.conflict("{s} is already taken", .{email}),
else => return err,
};
ForeignKeyViolated has no default for the same reason, and it is the one
worth knowing about. It is the only constraint failure that is routinely a
race rather than a bug: a delete guarded by a count is right up until somebody
adds a child row between the two statements.
_ = db.delete(User, c, .{ .where = .{ .id = id } }) catch |err| switch (err) {
error.ForeignKeyViolated => return nilo.fail.conflict(
"{s} placed an order a moment ago and can no longer be deleted. " ++
"Deactivate them instead.",
.{name},
),
else => return err,
};
And when the name is not enough, sql.problem(c) is what the database
actually said (ADR 117).
A table with two unique indexes on it raises one error for both; the
constraint field is what says which:
const said = sql.problem(c) orelse return err;
if (std.mem.eql(u8, said.constraint, "users_email_key")) {
return nilo.fail.conflict("that email is already listed", .{});
}
It answers for the last statement this fiber ran, and null when it worked.
Read it in the catch: it lives as long as the request does, and the next
statement replaces it. db.watching is the other end of the same information
and is for logging every statement rather than branching on one.