diff --git a/lessons/08-programmability/04-procedures/lesson.mdx b/lessons/08-programmability/04-procedures/lesson.mdx new file mode 100644 index 0000000..9682b80 --- /dev/null +++ b/lessons/08-programmability/04-procedures/lesson.mdx @@ -0,0 +1,191 @@ +Functions compute a value and hand it back to a query. A *procedure* is different: it runs for its side effects, returns nothing to a surrounding `SELECT`, and is invoked with its own statement — `CALL`. That distinction unlocks the one thing a function can never do: manage transactions from the inside, committing work as it goes. + +The seed is a work queue: a `jobs` table with 500 rows all marked `pending`, plus an empty `archived_jobs` table we'll fill later. + + +SELECT status, count(*) FROM jobs GROUP BY status; + + +## `CREATE PROCEDURE` and `CALL` + +A procedure looks like a function without a `RETURNS` clause. Here's one that marks every pending job as done. Define it first: + + +CREATE PROCEDURE mark_all_done() +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE jobs SET status = 'done' WHERE status = 'pending'; +END; +$$; + + +You can't `SELECT mark_all_done()` — a procedure isn't an expression. You invoke it with `CALL`, which is a statement all on its own: + + +CALL mark_all_done(); + + +Check the result — everything is `done` now: + + +SELECT status, count(*) FROM jobs GROUP BY status; + + +So far this is just a function you call awkwardly. The real reason procedures exist is the next part. Let's reset the queue before moving on: + + +UPDATE jobs SET status = 'pending'; + + +## Procedures can control transactions + +Inside a function, the whole call runs in one transaction that the caller owns — a function cannot `COMMIT` or `ROLLBACK`. A procedure can. That's the headline feature. + +Why does it matter? Imagine processing all 500 jobs in a single `UPDATE`. It works, but it's one giant transaction: it holds row locks on every touched row until the very end, and if it fails at row 499 you lose all the work. Batch and maintenance jobs want the opposite — process a chunk, commit it, release those locks, move on. A crash then costs you one chunk, not the lot. + +Here's a procedure that walks the queue in chunks of 100 and commits after each one: + + +CREATE PROCEDURE process_jobs(batch_size int DEFAULT 100) +LANGUAGE plpgsql +AS $$ +DECLARE + touched int; +BEGIN + LOOP + UPDATE jobs + SET status = 'done' + WHERE id IN ( + SELECT id FROM jobs WHERE status = 'pending' + ORDER BY id LIMIT batch_size + ); + GET DIAGNOSTICS touched = ROW_COUNT; + EXIT WHEN touched = 0; + RAISE NOTICE 'committed a batch of %', touched; + COMMIT; + END LOOP; +END; +$$; + + +The `COMMIT` inside the loop is the whole point: each pass through finalizes its batch and starts a fresh transaction for the next one. `GET DIAGNOSTICS ... = ROW_COUNT` tells us how many rows the last `UPDATE` touched, and we stop once a batch comes back empty. + +Now run it. Watch for the `NOTICE` lines — one per committed batch: + + +CALL process_jobs(100); + + +Five batches, five commits, 500 jobs done: + + +SELECT status, count(*) FROM jobs GROUP BY status; + + +## The gotcha: `COMMIT` needs to own the transaction + +There's a rule that trips everyone up: a procedure can `COMMIT` **only when the `CALL` is not already inside an outer transaction block**. If you wrap the call in `BEGIN ... COMMIT` yourself, the procedure isn't in charge of the transaction — you are — and its internal `COMMIT` will error out. + +A plain `CALL` on its own (autocommit) works, which is what you just did. But this pattern fails: + +```sql +BEGIN; +CALL process_jobs(100); -- error: invalid transaction termination +COMMIT; +``` + +The fix is simply to `CALL` the procedure as its own statement, not inside an explicit `BEGIN`/`COMMIT`. The same is true from application code: don't open a transaction around a procedure that commits internally — let it manage its own. + +## Returning a value with `INOUT` + +Procedures don't `RETURN`, but they can still hand data back through `INOUT` parameters. An `INOUT` parameter is passed in *and* sent back out; the `CALL` returns a one-row result with the final values. Here's a procedure that archives one batch and reports how many it moved: + + +CREATE PROCEDURE archive_batch(batch_size int, INOUT moved int DEFAULT 0) +LANGUAGE plpgsql +AS $$ +BEGIN + WITH picked AS ( + DELETE FROM jobs + WHERE id IN ( + SELECT id FROM jobs WHERE status = 'done' + ORDER BY id LIMIT batch_size + ) + RETURNING id, payload + ) + INSERT INTO archived_jobs (id, payload) + SELECT id, payload FROM picked; + GET DIAGNOSTICS moved = ROW_COUNT; +END; +$$; + + +Because it has an `INOUT` parameter, `CALL` gives you a result row back — pass a placeholder for the out value: + + +CALL archive_batch(50, NULL); + + +That moved 50 finished jobs into `archived_jobs` and told you the count. Let's clean up so the exercise below starts from a known state — put those 50 back as done: + + +INSERT INTO jobs (payload) +SELECT payload FROM archived_jobs; + + + +UPDATE jobs SET status = 'done' WHERE status = 'pending'; + + + +TRUNCATE archived_jobs; + + +## Your turn + +Every job is now `done`. Write a procedure `archive_done_jobs()` that moves **all** finished jobs out of `jobs` and into `archived_jobs` — deleting them from `jobs` in the same step so they aren't archived twice. Use the `DELETE ... RETURNING` into `INSERT` pattern from above, without the batch limit. Try it before peeking — here's one way to define it: + + +CREATE PROCEDURE archive_done_jobs() +LANGUAGE plpgsql +AS $$ +BEGIN + WITH picked AS ( + DELETE FROM jobs WHERE status = 'done' + RETURNING id, payload + ) + INSERT INTO archived_jobs (id, payload) + SELECT id, payload FROM picked; +END; +$$; + + +Now `CALL` it as its own statement so it owns its transaction: + + +CALL archive_done_jobs(); + + +See what landed — all 500 jobs are now in the archive, and `jobs` is empty: + + +SELECT + (SELECT count(\*) FROM archived_jobs) AS archived, + (SELECT count(\*) FROM jobs) AS remaining; + + + +Define `archive_done_jobs()` and `CALL` it. We'll confirm `archived_jobs` holds all 500 jobs. + + +## What you learned + +- A procedure runs for side effects, returns nothing to a query, and is invoked with `CALL proc(args)` — not `SELECT`. +- `CREATE PROCEDURE` has no `RETURNS`; the body is a `BEGIN ... END` block, just like a `plpgsql` function. +- The defining feature: a procedure can `COMMIT` and `ROLLBACK` mid-run. Functions can't — they run inside the caller's single transaction. +- That enables chunked batch jobs: process a batch, `COMMIT`, release locks, repeat — a failure costs one batch instead of everything. +- The gotcha: an internal `COMMIT` works only when the `CALL` isn't already inside an outer `BEGIN`/`COMMIT`. Call the procedure as its own statement. +- Procedures return data through `INOUT` parameters; the `CALL` yields a one-row result with the final values. + +Up next: Module 9 — Concurrency, starting with MVCC and isolation levels. diff --git a/lessons/08-programmability/04-procedures/lesson.yaml b/lessons/08-programmability/04-procedures/lesson.yaml new file mode 100644 index 0000000..655775e --- /dev/null +++ b/lessons/08-programmability/04-procedures/lesson.yaml @@ -0,0 +1,19 @@ +title: Procedures +summary: Stored procedures with CREATE PROCEDURE and CALL — and the one thing functions can't do, managing transactions with COMMIT and ROLLBACK mid-run. +estimatedMinutes: 14 +tags: + - procedures + - create-procedure + - call + - transactions + - commit +authors: + - exekias +seed: seed.sql +checks: + - id: jobs-archived + type: row-count + description: Process every job, then CALL a procedure that moves the finished jobs into archived_jobs. + table: archived_jobs + expect: + rowCount: 500 diff --git a/lessons/08-programmability/04-procedures/seed.sql b/lessons/08-programmability/04-procedures/seed.sql new file mode 100644 index 0000000..9584a37 --- /dev/null +++ b/lessons/08-programmability/04-procedures/seed.sql @@ -0,0 +1,22 @@ +-- Seed for "04-procedures": a work queue to churn through in batches. jobs holds +-- 500 pending tasks; the lesson writes a procedure that processes them in chunks +-- and COMMITs after each chunk. archived_jobs starts empty — the "Your turn" +-- exercise fills it by CALLing a procedure that moves finished work aside. + +CREATE TABLE jobs ( + id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + payload text NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'done')), + created_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO jobs (payload) +SELECT 'task #' || g +FROM generate_series(1, 500) AS g; + +CREATE TABLE archived_jobs ( + id int PRIMARY KEY, + payload text NOT NULL, + archived_at timestamptz NOT NULL DEFAULT now() +); diff --git a/lessons/08-programmability/module.yaml b/lessons/08-programmability/module.yaml new file mode 100644 index 0000000..51cb120 --- /dev/null +++ b/lessons/08-programmability/module.yaml @@ -0,0 +1,3 @@ +title: Programmability +difficulty: advanced +summary: Put logic in the database — views, functions, triggers, and stored procedures.