Repository navigation
Upgrade Crystal 1.21.1, kemal 1.14, sqlite3 0.23, and fix parser CI - #24
Merged
Merged
Conversation
- install-crystal v1.9.2 installs packages without apt-get update, so stale runner indexes 404 on libevent-dev (crystal-lang/install-crystal#82) - cron ORs day-of-month with weekday, so '1-7,15-21 * 0' ran daily
Alpine 3.23 ships Crystal 1.18.2, which lacks the HTTP::Server request smuggling fixes in 1.19.2 to 1.21.1. The official image is multi-arch; the runtime stays on its Alpine 3.22 base. libevent is unused since the epoll event loop.
- kemal 1.13 and 1.14 carry security fixes; 1.14 discards bodies written before an error, so error bodies travel with the exception and are printed by error handlers - unmatched routes answer JSON 404, wrong methods 405 with Allow - micrate pinned db 0.11 and sqlite3 0.19; app/lib/migrator.cr replaces it, keeps the micrate_db_version table, and fails boot on a broken migration - require Crystal >= 1.21.1
- DROP TABLE on a parent cascaded into child rows once foreign keys were enforced, deleting clicks when upgrading a July 2024 database - each file runs in BEGIN IMMEDIATE on one connection and fails only on new foreign key violations, so orphans from unenforced years cannot block boot - down rolls back the newest applied version and refuses an empty Down section
- the migrator and CLI connected with the bare DATABASE_URL and missed WAL, foreign keys, and busy_timeout - busy_timeout stays at 100 ms because SQLite's busy wait blocks the single-threaded scheduler - migrations run from bit.cr and the CLI instead of the Database class body, so cli --migrate-down does not migrate up first
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
The scheduled Update Parsers workflow has failed on master since October 1.
crystal-lang/install-crystal@v1(v1.9.2) installslibevent-devwithout refreshing apt, and the runner image now ships a stale index, so the download returns 404 (crystal-lang/install-crystal#82, fixed upstream but not released). Both workflows now runsudo apt-get updatefirst. The schedule0 0 1-7,15-21 * 0also ran every day, because cron matches either the day of month or the weekday, so it is now0 0 1,15 * *.The Docker build now uses the official multi-arch
crystallang/crystal:1.21.1-alpineimage. The previous Alpine 3.23 package was Crystal 1.18.2, so production missed the HTTP::Server request smuggling and request body fixes released in 1.19.2 through 1.21.1. The runtime stage moves toalpine:3.22to match the libraries the build image links against, and libevent is gone because Crystal uses its own epoll event loop on Linux.shard.ymlnow requires Crystal 1.21.1.Kemal moves from 1.12.0 to 1.14.0 for its security fixes, including HEAD requests bypassing GET filters and route keys built from malformed methods. Kemal 1.14 discards any body written before an error, which emptied every API error response. The app exceptions now carry their JSON body, and
errorhandlers inroutes.crprint it for 400, 401, 403, 404, 405, 413, 422, and 500.Micrate pinned
db ~> 0.11andsqlite3 ~> 0.19and has had no release since 2024. It is replaced byapp/lib/migrator.cr, which applies the Up section of pending migrations at boot and rolls back the newest applied one withcli --migrate-down, oneBEGIN IMMEDIATEtransaction per file. The migrator reads and writes micrate's existingmicrate_db_versiontable, so deployed databases keep their history. This unlocks sqlite3 0.23.0, which keeps bound arguments alive until a statement runs, and db 0.14.0, which fixes multi-threaded pool races. The unusedmysqlandpgshards disappear with micrate. The benchmark runs migrations in-process instead of requiring the micrate binary.A new
AGENTS.mdgives a code map, the constraints that are easy to miss, the check order, and manual verification steps beyond the e2e suite: running the Docker image, a bash walkthrough of every endpoint and its edge cases, and load, throughput, and memory measurement.Behavior changes
404 {"error":"Resource not found"}as JSON. Before, Kemal rendered its own 404 page.405 {"error":"Method Not Allowed"}with anAllowheader. Before, it returned 404.journal_mode=WAL,synchronous=NORMAL, andforeign_keys=true, plus a newbusy_timeout=100. The app, the migrator, and the CLI share them throughApp::Lib::Database::URL; a key already present inDATABASE_URLwins. crystal-sqlite3 0.19 ignored URL parameters, so master never applied them. On first boot an existing database switches to WAL, anddata.db-walanddata.db-shmfiles appear next to it in thesqlite/volume.ON DELETE CASCADErules take effect: deleting a user deletes their links and clicks, and a click recorded after its link was deleted is rejected and logged instead of stored as an orphan.20240714215409dropslinksand the cascade deleted every click of a database upgraded from July 2024 (reproduced: 3 clicks became 0). A migration fails only if it adds a foreign key violation, so orphan rows from the unenforced years do not block boot. One limit predates this PR: a database older than20250319192003that holds orphan clicks or links cannot run that migration (NOT NULL constraint failed: clicks_new.link_id).cli --migrate-downrolls back the newest applied migration with its Down section and refuses an empty one, so20250319192003is irreversible. The current image re-applies a rolled-back file on its next start, so a rollback only sticks when an older image is deployed next.docker exec ... cli, now waits up to 100 ms for the lock instead of failing withdatabase is locked. The timeout stays short because SQLite's busy wait blocks the single-threaded scheduler, so a long one would stall every request.Benchmark
Tested on the same laptop in the same session, with 100,000 requests and 125 connections against the redirect endpoint. Both builds recorded every click.
Apart from the first run after a build, the branch holds at about 21k req/s, roughly 6x master.
The gain comes from SQLite's journal mode, not from dependency code. crystal-sqlite3 0.19 opened the database without reading the URL's query parameters (connection.cr has a
TODOfor it), while 0.20 and later run them as PRAGMAs on every connection (connection.cr). On master every click insert therefore committed in rollback-journal mode withsynchronous=FULL, and those blocking fsyncs stalled the single-threaded server. Two experiments confirm it:The README and
docs/SETUP.mdnow show the median run (21,141 req/s, 5.91 ms average latency, 32.14 MiB average memory), and the SETUP run step builds withENV=production, which is how the numbers were measured.Checks
ENV=production shards build --release --no-debugforbit,cli, andbenchmark, with no warningsENV=test crystal specwith 61 passing examples, including migrator specs for micrate history, transaction rollback on failure, a July 2024 database upgraded through every real migration with foreign keys on, tolerated orphans and rejected new violations, rollback and the irreversible refusal, and JSON 400, 404, 405, and 413 responses. The July 2024, 413, and foreign key restore specs fail when their fix is removedENV=test crystal spec e2edocker buildforlinux/arm64andlinux/amd64, each smoke tested by creating a link20260808000000_add_pagination_indexes.sql, then rolled back and re-applied itAGENTS.mdrun against the built image: 32 endpoint status checks matched, GeoIP resolved8.8.8.8toUSinside the image, and the container used 30 MiB idle and 51 MiB under 100,000 requestsAssumptions
statusor scaffold command, and noStatementBeginsupport. No migration uses them, and the parser raises on any other-- +micratedirective so a future need fails loudly.busy_timeoutis 100 ms rather than seconds. This would flip if the app runs with a multi-threaded execution context, where a blocked thread no longer stalls every request.checkout@v4,docker/*,create-pull-request@v6) keep working today and are left for a separate change.Refuted evidence
crystallang/crystal:1.21.1-alpineimage was reported to be based on Alpine 3.24. It is based on Alpine 3.22.6 for both architectures, so the runtime stage usesalpine:3.22instead of 3.24.Rejected review findings
{"errors": ...}shape for the generic 422 fallback: Kemal never raises 422 itself, and the handler exists to print the app's own 422 body.AGENTS.mdendpoint lines that the in-process specs already cover: the walkthrough exists to check every endpoint against the published image over a real network, which the specs do not do.DB::Database(the specs) keep the connection, and hiding the error would leave foreign keys off silently.foreign_key_checkto files that touch tables with foreign keys: it runs only at boot for pending files, and a full check is the simpler guarantee.cli --update-parsers: the extra migration is harmless and keeps one rule for every CLI command except--migrate-down.DATABASE_URLdefault inapp/config/env.cr: the ordering predates this PR and production does not use dotenv.