Stored procedures
A procedure is a statement list stored in the catalog and run by CALL.
A fixed sequence of statements then costs one round trip instead of N, and
the sequence lives with the schema rather than being copy-pasted into every
client.
CREATE PROCEDURE archive_order(order_id TEXT)
BEGIN
INSERT INTO archive (id, at) VALUES (order_id, now());
DELETE FROM orders WHERE id = order_id;
SELECT id, at FROM archive WHERE id = order_id;
END;
CALL archive_order('o-1'); -- returns the last statement's rows
DROP PROCEDURE [IF EXISTS] archive_order;
SHOW PROCEDURES; -- name, params, definer, created
The whole surface is ordinary SQL, so every driver works unmodified —
there is no new wire opcode and no new result shape. CALL p(?, ?) prepares
like any other statement, so arguments bind through the driver's normal
parameter path.
The body
A body is a ;-terminated list of steps between BEGIN and END. Without a
RETURN, the last statement's result is the call's result; intermediate
result sets are discarded, so a body that reads a million rows and then
selects one returns one row.
Every statement needs its ;, including the last one. Without a
terminator, SELECT a FROM t END reads END as a column alias and swallows
the body's terminator. Block and loop terminators (END IF, END WHILE,
END FOR) close their own construct and a trailing ; after them is
optional.
What a body may contain:
| Allowed | Refused |
|---|---|
SELECT, INSERT, UPDATE, DELETE |
DDL of any kind |
CALL of another procedure |
BEGIN / COMMIT / ROLLBACK |
Dynamic SQL (there is none — no EXECUTE '<string>') |
Each refusal is a decision, not a gap:
- No DDL. A table that does not exist when the procedure is created cannot be resolved, which would make the privilege footprint below unknowable — and a scheduled body (a later phase) would put a cluster-wide DDL round on the wire on every run.
- No transaction control. A procedure is not a transaction. It may run
inside
BEGIN ATOMIC PARTITION, where its writes buffer and commit with everything else in the partition; it may not open one itself. - No dynamic SQL. A string is not analysable, so a body that built one
would have no knowable footprint at all. This is the reason
EXECUTE '<string>'does not exist and will not.
Control flow
CREATE PROCEDURE reconcile(cutoff INT)
BEGIN
DECLARE n INT DEFAULT 0;
DECLARE label TEXT;
SELECT count(*) INTO n FROM orders WHERE total > cutoff;
IF n = 0 THEN
RETURN {matched: 0, label: 'none'};
ELSEIF n > 100 THEN
SET label = 'many';
ELSE
SET label = 'few';
END IF;
FOR r IN (SELECT id, total FROM orders WHERE total > cutoff) DO
INSERT INTO flagged (id, total) VALUES (r.id, r.total);
END FOR;
RETURN {matched: n, label: label};
END;
| Construct | Notes |
|---|---|
DECLARE v <TYPE> [DEFAULT <expr>] |
Scoped to the enclosing block; starts NULL without a DEFAULT. Redeclaring in the same block is an error. |
SET v = <expr> |
The variable must be declared — assigning an unknown name is an error, not a silent NULL. |
SELECT … INTO v1, v2 FROM … |
The first row's columns, positionally. Sets FOUND; with no row the targets become NULL. |
IF … THEN … [ELSEIF … THEN …] [ELSE …] END IF |
Only a true condition takes a branch: a NULL condition behaves like false, as in a WHERE. |
WHILE <cond> DO … END WHILE |
See termination below. |
FOR v IN (<query>) DO … END FOR |
Each row is bound to v as a document, read as v.column. |
LEAVE |
Exits the innermost WHILE/FOR. |
RETURN [<value>] |
Stops the body. See What a call returns. |
BEGIN … END |
A nested block: a scope, and the only place a handler attaches. |
Variables substitute into statements exactly as parameters do, so the same
shadowing rule applies — DECLARE id INT next to WHERE id = id is refused
for the same reason and with the same message.
What a call returns
RETURN {k: <expr>, …}— one row, one column per field, in written order. The fields are expressions, unlike a plain{…}literal elsewhere in SQL, which is constant-only.RETURN <expr>— one row, one column namedresult.RETURNwith no value, or falling off the end — the last statement's result set.
Termination
There is no iteration cap. A WHILE is bounded by the caller's scan budget
and deadline, which is the language's only termination guarantee and the
reason WHILE 1 = 1 cannot run forever: the body inherits one ceiling for the
whole call, and every row any statement examines counts against it.
That guarantee is why a handler cannot catch a resource limit (below), and why a scheduled job — which has no caller to inherit from — will have to install a budget and deadline explicitly.
Exception handlers
A block may end with handlers, which is what lets a body swallow one bad row and carry on instead of stalling:
BEGIN
INSERT INTO t (id, email) VALUES (k, mail);
EXCEPTION
WHEN UNIQUE_VIOLATION THEN
INSERT INTO rejects (id, why) VALUES (k, error_message);
WHEN OTHERS THEN
INSERT INTO rejects (id, why) VALUES (k, error_message);
END;
Inside a handler, error_kind and error_message are bound to what was
caught. Conditions:
| Condition | Catches |
|---|---|
NOT_FOUND |
a table, index or database that does not exist |
CONSTRAINT |
constraint violations, "already exists" |
UNIQUE_VIOLATION |
a UNIQUE index collision |
FOREIGN_KEY_VIOLATION |
a child row without its parent, or a RESTRICT parent write |
NOT_NULL_VIOLATION |
a row that lands without a NOT NULL column (absent or NULL) |
CHECK_VIOLATION |
a row for which a CHECK expression is false |
TYPE_ERROR |
type errors |
UNSUPPORTED |
unsupported statements and shapes |
OTHERS |
any of the above |
What no handler can catch, and why it matters:
- Resource limits — the scan budget, the statement deadline,
KILL QUERY, and the call-depth cap. A body that could catch its own timeout could loop forever inside the handler, which would take away the only termination guarantee the language has. - Storage, I/O and cluster errors. A peer being unreachable is exactly the failure a handler is tempted to swallow, and swallowing it turns "I could not read that shard" into "there was nothing there" — a silent wrong answer rather than an error.
Cursors
A cursor sweeps a table in constant memory, which is what FOR … IN
cannot do:
DECLARE c CURSOR FOR SELECT id, total FROM orders WHERE total > 100;
DECLARE k TEXT;
OPEN c;
FETCH c INTO k;
WHILE FOUND DO
-- …work on k…
FETCH c INTO k;
END WHILE;
CLOSE c;
FOUND is set by every FETCH (and by SELECT … INTO), which is the loop
idiom above. Fetching from a cursor that is not open is an error.
A cursor is a resumable keyset scan over the primary key, not a snapshot. It resumes by position, so rows written while it is open are visible to it, exactly as re-running the query would be. Nothing pins a read view: holding one for the life of a body is memory this database will not hold.
That decision has consequences worth knowing before you rely on one:
- The query must be a plain
SELECTof columns from one table with an optionalWHERE, on a single-column primary key — no join, grouping, ordering, limit or set operation. Anything else is refused with the reason. - Resuming needs a read path that can push the cursor into its walk. On a
cluster that means consistency
ONEon a fully-replicated table; otherwise the second page refuses loudly rather than restarting from the top, because a cursor that silently re-serves page one would loop a body forever.
FOR … IN is the convenient alternative: it materializes the query's rows,
bounded by the same byte budget as the identical SELECT typed by hand, so
it cannot run away — but for a sweep larger than that budget, use a cursor.
Calling another procedure
A body may CALL another procedure. The callee must exist when the caller is
created, and its own footprint is folded into the caller's — see Privileges
below, where the call graph is walked rather than snapshotted.
Recursion is bounded by a depth cap of 16 nested calls, not by a rule against cycles. A cycle cannot be refused at creation: either half can be dropped and recreated after the other was checked. Exceeding the cap aborts the run with a resource-limit error, which — like a scan budget or a deadline — a body cannot catch, so it cannot recurse again from inside its own handler.
Errors name the statement that failed, because a body is a single CALL to
the client:
procedure archive_order, statement 2: no such table: shipments
Parameters
Each parameter declares a type:
| Type | Accepts | Also written |
|---|---|---|
TEXT |
strings, UUIDs | STRING, VARCHAR |
INT |
integers | INTEGER, BIGINT |
FLOAT |
floats, decimals, integers | DOUBLE, REAL |
BOOL |
booleans | BOOLEAN |
TIMESTAMP |
timestamps, epoch-ms integers | |
JSON |
documents | DOCUMENT |
ARRAY |
arrays | |
ANY |
anything |
The store is schema-less, so a type is a contract on the call and nothing
else: it is checked against the argument and never against storage. NULL
satisfies any type — it is the absence of a value, not a wrong one — and
ANY opts out.
Arguments must be constant. A call site has no row in scope, so a column
reference could only ever read NULL; it is refused by name instead.
Constant expressions do work:
CALL sweep_expired(now());
A parameter shadows an unqualified column
Inside a body, every unqualified reference to a parameter's name becomes
the argument. That is what makes WHERE order_id = order_id_param read
naturally — and it is also a trap when the names collide:
-- REFUSED: `id` is both the parameter and the column
CREATE PROCEDURE archive(id TEXT)
BEGIN
DELETE FROM orders WHERE id = id; -- would become 'o-1' = 'o-1' — every row
END;
skaidb refuses this at CREATE PROCEDURE whenever the column is declared
— a primary-key column, a series key, or an indexed path — which is what a
predicate is usually written on. Two ways out, both in the error message:
- rename the parameter (
p_id,order_id), or - qualify the column:
orders.idis never substituted, because only unqualified names are.
Columns that are not declared anywhere cannot be detected in a schema-less store, so prefer parameter names that cannot collide.
Which database a body belongs to
Unqualified names in a body resolve against the database of the session that
created the procedure, and are stored qualified. A procedure created in
shop reads shop.orders no matter who calls it — otherwise the same
procedure would mean different things per caller.
Procedure names themselves are never db.-qualified (like index names): a
procedure lives in one database, and SHOW PROCEDURES lists the current
one's.
Privileges
CALL runs with invoker rights. Two checks, both required:
EXECUTEon the procedure.- Every privilege the body's own statements need.
The second is the important one — without it, GRANT EXECUTE on a procedure
that deletes rows would hand a read-only role a delete. Each procedure
records, at CREATE PROCEDURE, the privilege its own statements need on each
canonically-resolved table. A CALL checks the union over the whole call
graph: what a nested body does, the caller must be allowed to do, or a thin
wrapper would be a way to borrow privileges.
The graph is walked at call time rather than snapshotted at creation, so redefining a callee changes its callers' requirements immediately — a snapshot would keep authorizing a body that no longer exists.
GRANT EXECUTE ON PROCEDURE archive_order TO app; -- the gate
GRANT INSERT ON archive TO app; -- what the body does
GRANT DELETE ON orders TO app;
GRANT SELECT ON archive TO app;
A grant on the whole database covers procedures in it, exactly as it covers tables:
GRANT EXECUTE ON DATABASE app TO reporting;
Creating a procedure needs CREATE (on the procedure, or on its database);
dropping one needs DROP. SHOW PROCEDURES needs no privilege — it lists
names, parameters, definer and creation time, never the body, which would
name tables the listing role may not read.
The definer is the role that created the procedure, recorded by the
server from the authenticated connection. A DEFINER clause typed by a
client is discarded, so it cannot be forged. It is informational today;
scheduled jobs and triggers will run as it.
Where it runs
A CALL executes at one coordinator — whichever node received it — and
its writes replicate through the ordinary write path, exactly like the same
statements typed by hand. The call itself is never shipped to each replica:
a body can read at a different convergence state or use now(), and
re-executing it on three nodes would produce three different answers that
anti-entropy could not reconcile, because anti-entropy reconciles rows, not
intentions.
Consequences worth knowing:
- A procedure is not atomic. Statement 3 can fail after statements 1 and
2 have committed. For all-or-nothing across one partition,
CALLinsideBEGIN ATOMIC PARTITION. There, the call is checked against every table the body could write — transitively, including nested calls — before any of it runs, so a procedure that could write outside the pinned partition is refused whole rather than leaving half its writes buffered. - The body inherits the caller's scan budget and deadline, as one ceiling for the whole call rather than one per statement. A body that walks too much is stopped by the same machinery that stops a runaway query, and that limit is reported as itself — a procedure cannot disguise its own budget or deadline as an ordinary body error.
Running a procedure on a schedule
CREATE JOB hourly ON SCHEDULE EVERY '1h' CALL sweep_expired();
CREATE JOB nightly ON SCHEDULE CRON '0 3 * * *' CALL rebuild_rollups()
WITH (timezone = '+02:00', timeout = '5m', budget = 2000000, ttl = '7d');
SHOW JOBS; -- name, source, procedure, definer, owner,
-- next_run, last_run, last_status, last_error, failures
DROP JOB [IF EXISTS] nightly;
Both schedule forms are accepted because neither subsumes the other: EVERY
is a duration since the last tick and drifts relative to the calendar, while
CRON names calendar instants and is the only way to say "3am daily". Both
reduce to the same stored field — the next run's absolute instant.
| Option | Default | Meaning |
|---|---|---|
timezone |
UTC |
The offset a CRON expression is evaluated at. |
catchup |
false |
Run once on recovery after a missed window instead of skipping to the next tick. |
timeout |
5m |
Wall-clock ceiling for one run. |
budget |
1000000 |
Row budget for one run. |
ttl |
7d |
How long this job's run history is kept. |
result_cap |
4096 |
Byte cap on a run's stored RETURN document. |
timeout and budget are not tuning knobs. A CALL inherits its
caller's scan budget and deadline, which is what bounds a body; a scheduled
run has no caller, and an unset budget means unbounded. So a job always
runs with both installed — these options only choose the numbers.
Timezones are fixed offsets
UTC (the default) and +HH:MM / -HH:MM are accepted; a named zone like
Europe/Warsaw is refused. skaidb embeds no timezone database, and a
cron job that silently ran an hour off for half the year would be worse than
one that refuses the spelling. A fixed offset has no DST transitions, so the
skipped-hour and repeated-hour ambiguities do not arise.
Cron syntax
Five fields — minute hour day-of-month month day-of-week — with *, a,
a-b, a,b, */n and a-b/n, plus three-letter month and day names
(jan, mon). Sunday is 0 or 7. There is no seconds field: anything
finer than a minute is what EVERY is for.
When both day-of-month and day-of-week are restricted, a day matching
either fires (0 3 1 * mon = the 1st and every Monday) — the classic cron
rule. When only one is restricted, only that one decides.
A schedule that can never fire (0 0 30 2 * — February 30th) is refused at
CREATE JOB rather than silently never running.
Which node runs it
Pick with the ring, be correct with a lease. At RF=3 every replica sees every job, so the naive scheduler would fire everything three times. The ring's preferred owner keeps a job on one node with no coordination and rebalances for free when membership changes; a linearizable CAS lease on the job's state row is what makes ownership correct in the window where two nodes can both believe they own a key. The lease expires, so an owner that dies is replaced with no operator action.
The guarantee is at-least-once. An owner can run a body and die before recording it, so the same tick can execute twice — see writing a job body below, which is how you make that harmless.
The scheduler wakes every 5 seconds, so a schedule finer than that fires at
the tick rate, not the rate you asked for. EVERY '500ms' is not an error;
it just runs about every 5 seconds and records the ticks it skipped.
A job under memory or disk pressure is deferred: the run does not start
and the tick is retried, rather than being recorded as complete. Deferral is
invisible in the job's own state — a shed job looks like a healthy quiet one
— so it is counted in skaidb_jobs_deferred_total.
Writing a job body
At-least-once means a body that inserts can insert twice. Four values make
that survivable, and they are ordinary zero-argument calls inside the body or
in the job's CALL arguments:
SCHEDULED_AT() |
the logical tick, identical across every attempt of the same run. Key your writes on it (INSERT … ON CONFLICT) and a retry overwrites instead of duplicating. |
CURRENT_RUN() |
a stable id for this attempt — for correlating diagnostics with _job_runs. Not an idempotency key: a retry is a new run. |
LAST_SUCCESS() |
finished_at of the last ok run, or NULL. |
PREVIOUS_RESULT() |
that run's RETURN document. |
The last two are what make "process everything since last time" expressible
without the body maintaining its own watermark table — and the watermark is
then visible to an operator in _job_runs instead of hidden in user data:
CREATE PROCEDURE roll(since TIMESTAMP)
BEGIN
DECLARE high TIMESTAMP;
SELECT max(ts) INTO high FROM events WHERE ts > since;
INSERT INTO rollup (id, n)
SELECT … ; -- your aggregation
RETURN {watermark: high};
END;
CREATE JOB roll_up ON SCHEDULE EVERY '5m' CALL roll(LAST_SUCCESS());
A RETURN document becomes the run's result (capped by result_cap, and
flagged rather than silently truncated when it does not fit) and the next
run's PREVIOUS_RESULT().
What a run recorded
_job_runs carries the outcome, timings, the node, the RETURN document —
and what the run actually did, because "it succeeded in 40 ms" does not
answer what did last night's run change?, which on a schedule is the only
question anybody asks:
SELECT scheduled_at, status, effects, tables_written FROM _job_runs
WHERE job = 'roll_up' ORDER BY id DESC LIMIT 5;
effects: [ {"stmt": 3, "executions": 1, "rows": 1240, "ms": 812},
{"stmt": 5, "executions": 1240, "rows": 1240, "ms": 3100} ]
tables_written: ["rollup"]
effects is keyed by statement site — the position in the code — not by
execution. A WHILE loop running ten thousand statements yields one entry
per site, so a run record is bounded by the length of the body rather than by
the volume of data it touched. That distinction is what keeps a busy job's
history from dwarfing its output.
Bounded by the code is still not bounded small, so the array keeps the
heaviest sites by time and rows and collapses the rest into one "+rest"
entry carrying their totals. It says so rather than silently ending — a
truncated list with no marker would read as "the body only did this".
tables_written is what the run actually changed, as opposed to the
static footprint, which is what it could.
For a trigger, the whole batch shares one record, and the effects accumulate across it: 256 events still produce one entry per statement site.
Failure, retry and history
A failed run is recorded, counted in failures, and its message kept in
last_error — a stalled job is visible in SHOW JOBS and in metrics, never
inferred from absence. Retries back off exponentially (capped at an hour), so
a broken job stops hammering its own history.
A node down across several windows skips to the present by default and
records how many ticks it skipped; WITH (catchup = true) runs the oldest
missed tick instead. Either way the decision is recorded rather than silent.
History is best-effort: a node that dies mid-body may leave no record. The lease, not the history, is what recovers the job.
A job runs as its definer
A schedule has no invoker, so a job records the role that created it and runs as that role. The server stamps it from the authenticated connection, so a role can only create a job that runs as itself — there is no spelling a client can use to run one as anybody else. This is the privilege-escalation surface of the whole feature, which is why it is decided by the server rather than by anything in the statement.
CREATE JOB therefore requires everything a CALL of the same procedure
would, checked at declaration — and re-checked before every run, so a
privilege revoked afterwards stops the job rather than leaving it running
with rights its definer no longer has. A run refused that way is recorded as
a failure, with the reason.
The job tables
Two ordinary replicated tables, created with the first job in a database:
| Table | Holds |
|---|---|
_job_state |
one row per job: owner, lease, next/last run, last status and error, consecutive failures |
_job_runs |
one row per run, TTL'd by the job's ttl |
They are ordinary tables on purpose — that is what buys replication,
retention, SQL reads and paged replay — and read-only to clients, for the
same reason a stream's log is: a hand-written row is indistinguishable from a
real one, and dropping _job_state would take the scheduler down. A stream
over either is refused too, since it would capture its own writes.
DROP JOB leaves the tables and the job's history behind: "what did it do
before I dropped it?" is the next question an operator asks.
Because _job_runs is a TTL table, its ttl bounds what you see; the
space comes back on the background reclaim sweep (see
QUERY_SYNTAX.md). Size it for the retention plus about one
sweep interval.
Running a procedure when a row changes
CREATE TRIGGER big_order ON orders WHEN (total > 1000)
CALL archive_order(NEW.id, OLD.total);
SHOW TRIGGERS; -- name, table, predicate, procedure, definer,
-- stream, cursor, last_status, last_error, failures
DROP TRIGGER [IF EXISTS] big_order;
A trigger is a job whose source is a change stream instead of a schedule. There is one concept underneath, which is why ownership, the lease, run history and failure handling are identical to a scheduled job — only "what is due" differs.
CREATE TRIGGER creates two things and owns both: a hidden stream
(__trg_<name>) carrying pre-images, and a job that consumes it.
DROP TRIGGER removes both — leaving the stream would keep doubling the
source table's write volume for a consumer that no longer exists.
The body sees NEW and OLD
NEW |
the row as it now is. NEW.total, NEW.id, … |
OLD |
the row as it was, or NULL for an insert. Test OLD IS NULL to tell "new row" from "changed row". |
EVENT() |
the event's id — the identity to key idempotent writes on here. |
OP |
put, exit (matched before, no longer does) or delete. |
Both come from the log, not from a re-read: the row may have changed
again since, and a trigger must act on the change it was told about.
SCHEDULED_AT() is NULL in a trigger — there is no tick.
It fires from the log, never on the write path
A matching write appends an event; a background consumer reads the log forward from its cursor and runs the body once per event. Inline firing is refused for three reasons, each already paid for once elsewhere:
- User code inside the write/applier path is a deadlock class.
- Capture must never fail the write. A trigger that errors cannot roll the write back — leaderless, already committed, three replicas — so an inline trigger could only choose between misreporting a committed write to the client and being ignored.
- Background work can be shed under load. Work welded into the write path cannot.
Firing from the log also gives retry and crash recovery for free, and means a trigger inherits everything the stream already does: the predicate is re-checked against every committed write, a write that lost LWW never fires, and a repair pass cannot replay ancient history as fresh triggers.
Delivery, failure and cost
- At-least-once, per event. Key writes on
EVENT(), which is minted from the change and identical on every replica. - One run record per BATCH, never per event — a record per fired trigger would re-double write volume on a hot table on top of the stream's own doubling. Always one on error.
- A poison event does not stall the trigger. It is retried a few times,
then skipped, counted and recorded. A cursor that never moves is a trigger
that has stopped without saying so;
SHOW TRIGGERSshows the cursor,last_errorandfailures. - A trigger costs writes. The stream roughly doubles the write volume of
what it matches, and carrying pre-images doubles that again.
SHOW TRIGGERSnames the stream so you can measure it (SELECT count(*) FROM _stream___trg_big). Narrow theWHEN.
Cascades are refused, not capped
A trigger's writes produce events that can fire triggers. A cycle among
them never terminates, so it is refused at CREATE TRIGGER:
trigger loop_self would cascade forever: its body writes 'a', which leads
back to 'a', the table it fires on.
The check walks the static write footprint of each trigger's procedure — transitively, through nested calls — across the trigger graph. A cascade without a cycle is a DAG and terminates by construction, so this bounds exactly what a runtime depth cap would, at declaration rather than at 3am.
(A runtime cap was the RFC's first shape. It needs a depth counter carried on the event to survive the consumer, and every replica emits its own copy of an event independently — so the stored depth would be whichever replica's write won last-writer-wins, and a cap built on it would fire or not fire at random.)
From an application
CALL is an ordinary statement over the existing protocol, so there is
nothing driver-specific to install and no new result shape to handle: the
arguments bind through the same prepared-statement path as any other
placeholder, and the rows arrive through the same cursor. Placeholders differ
per driver (? everywhere except Node.js and Ruby, which use $1) — see the
matrix in HOWDOI.md.
SQL
CALL archive_order(?);
Python
cur = conn.cursor()
cur.execute("CALL archive_order(?)", ("o-1",))
for id_, at in cur.fetchall():
print(id_, at)
Node.js
const res = await client.query('CALL archive_order($1)', ['o-1']);
for (const row of res.rows) console.log(row.id, row.at);
Go
rows, err := db.Query("CALL archive_order(?)", "o-1")
defer rows.Close()
for rows.Next() {
var id string
var at time.Time
rows.Scan(&id, &at)
}
Java
Skaidb.ResultSet rs = conn.prepare("CALL archive_order(?)")
.setString(1, "o-1")
.executeQuery();
while (rs.next())
System.out.println(rs.getString("id") + " " + (java.time.Instant) rs.getObject("at"));
Ruby
res = conn.exec_params("CALL archive_order($1)", ["o-1"])
res.each { |row| puts "#{row['id']} #{row['at']}" }
PHP
$stmt = $db->prepare('CALL archive_order(?)');
$stmt->execute(['o-1']);
foreach ($stmt->fetchAll() as $row) { echo $row['id'], ' ', $row['at'], PHP_EOL; }
C#
using var cmd = conn.CreateCommand();
cmd.CommandText = "CALL archive_order(?)";
cmd.Parameters.Add("o-1");
using var reader = cmd.ExecuteReader();
while (reader.Read()) Console.WriteLine($"{reader.GetString(0)} {reader.GetDateTimeOffset(1)}");
Rust
use skaidb_proto::Response;
use skaidb_types::Value;
let mut q = client.prepare("CALL archive_order(?)")?;
if let Response::Rows { rows, .. } =
client.execute_prepared(&mut q, &[Value::String("o-1".into())])?
{
for row in rows { println!("{} {}", row[0], row[1]); }
}
A call returns one result set, or none
What comes back is what the body returned — a
RETURN, or the last statement's result set. Two consequences are worth
knowing before you write the client half:
- Only the last result survives. A body that runs five
SELECTs hands back the fifth. There are no multiple result sets, so no driver needs anextResult()and none of them have one. - A body ending in a mutation returns no rows at all. The call still
succeeds and the affected-row count is still reported through the driver's
ordinary channel (
rowcount,RowsAffected(),executeUpdate()), but there is no result set and no column metadata —cur.descriptionisNonerather than an empty list. Iterating it is not an error; it simply yields nothing. If a caller needs data back, end the body with aSELECTor aRETURN.
Watching a job or a trigger
A scheduled or triggered run has no client to return to, so there is
nothing for a driver to fetch: the run's outcome is recorded instead, and you
read it with an ordinary SELECT through the same query call as anything
else.
SHOW JOBS; -- name, source, procedure, definer, owner, next_run,
-- last_run, last_status, last_error, failures
SELECT job, status, scheduled_at, started_at, finished_at, node,
error, effects, tables_written
FROM _job_runs ORDER BY id DESC LIMIT 20;
Those are all of a run's columns: finished_at - started_at is how long it
took, and scheduled_at is the logical tick it was for — which is not the
same instant, and the gap between them is how late the run was. Two fields
are written only when they apply: error on a failure, and missed when
ticks were skipped. The store is schemaless, so selecting one that a given
row does not carry yields NULL rather than an error — a successful run
reads back error = NULL, and a misspelled column name reads back NULL
too, which is worth knowing before you trust an empty result.
SHOW JOBS is the live view — where a job is now and when it fires next.
_job_runs is the history, newest last by key order, and it is where a
failure's error and a run's effects live. Both
are read-only to clients: the scheduler owns them, so a client can SELECT
them and nothing else.
The columns arrive typed, so started_at is a timestamp in the driver's
native type, and effects is a list of objects — one per statement site
that did work, which a driver hands back as its ordinary nested-document
type (a list of dict in Python, []any in Go):
[{"stmt": 1, "executions": 1, "rows": 1, "ms": 0}]
A trigger records its runs in exactly the same place under its own job name, so one query covers both.
Limits today
No OUT parameters and no multiple result sets — both need a protocol change,
so a call returns exactly one result. No dynamic SQL, and no user-defined
functions: a procedure is reachable by CALL only, never from inside an
expression, so it can never end up in a WHERE clause the planner would have
to encode for a peer.
RETURN is
scoped to the modes where a run IS one execution — manual and scheduled: a
trigger run is a batch of N executions, so a single result per run would
have no honest meaning. A trigger body does not need one, because its state
is the cursor.
Jobs and triggers run on a standalone server too, with the same policy: one node always owns the lease, and there is no ring to pick from.