LagomCMS, part 4: what building it corrected

On this page

This is the last of four posts about LagomCMS, the CMS that runs this site. The spec held up better than I expected and worse than I claimed. Here are the corrections, then where it stands now.

The corrections

  • New folders got missed. A watch is added to a new directory when its event is processed, up to 50 ms later, and a file written in that window was never seen. Creating a folder and its index.md at once hit this every time. Fix: sweep the directory the moment the watch is added.

  • Every editor save rescanned everything. Vim and sed -i save by writing a temp file and renaming it, which fires a delete event, and I treated any delete as "reconcile the whole tree". A one-file edit took four seconds at a thousand documents. The path is known, so now only that row changes.

  • nginx doesn't pass environment variables to workers unless you declare them with env. Every config variable read as nil, and nobody noticed for weeks because the values happened to match the defaults.

  • ngx.now() is a cached clock. It only moves when the event loop yields, so every index scan reported 0.0ms, including one over a thousand documents.

  • One stat process per document. A rebuild spawned stat a thousand times, about two of its three and a half seconds. find reports the mtime in the same pass.

  • Empty Lua tables become JSON objects. Every empty array in the generated OpenAPI document came out as {} instead of []. My own checks missed it. A real validator caught it in a second.

  • Pagination lost data. The next cursor came from the last row the caller could see, so a page of only hidden drafts produced no next link and everything after it became unreachable. I'd filed it as a performance note. It was data loss.

  • Listing returned one row per translation. Invisible until a second language existed, then the frontend showed the same post twice.

  • Lua's # operator is undefined on tables with holes. A null in a bind list silently dropped every parameter after it, which showed up as failed logins never reaching the audit log.

  • ngx.status and ngx.header are properties, not fields. Copying them into a table for plugins lost them, so plugin routes couldn't send a response at all.

  • Trash couldn't live under var/. A delete is a rename, and content and state are separate mounts.

  • The master process ran as root to bind a port above 1024, which needs no privilege.

  • Saving a post over about 8 KB failed in production. The API reads request bodies with get_body_data(), which returns nothing once nginx has spooled a large body to disk. The live site runs with a bigger buffer for now. The proper fix, falling back to the spooled file, is next on the list.

Two lessons

The measurements were wrong before the code was. Three performance budgets passed on instruments that measured nothing. Writing the benchmark found more bugs than writing the features.

Every bug lived in a seam. nginx and the environment, Lua and JSON, a filesystem and a mount point, an editor and a file watcher. Not one was a wrong algorithm.

Where it is now

LagomCMS runs dotmavriq.life in production:

  • One container at about 20 MB of memory, with no database process.

  • One folder per document on disk. The SQLite index, with FTS5 full-text search, rebuilds in under a second if you delete it.

  • 22 endpoints under /api/v1: filtering, full-text search, media uploads, cookie and token auth, and a generated OpenAPI document.

  • The model is plain Lua tables in collections/, and writes are hash-guarded, so the admin and Git can edit the same content.

  • This site's Astro frontend reads the same public API as anyone else.

It's version 0.1.0-dev, AGPL, and run by one person on one site, which is a long way from proven. If you're a developer self-hosting on small hardware and want content in Git with the model in code, it might suit you. Otherwise Payload, Kirby, Statamic, Sanity or WordPress are all good choices, depending on what you need. The code is on GitHub.