Repository navigation
v0.3.0
The new package events adds event sourcing to the writer, with inline and catch-up projectors, rebuilds, retention and reads as of any position. A page that is too slow for its stream now stays close to the present with newest-frame.js, a Datastar plugin that lith serves. It applies only the newest frame of each view, and asks for a slower stream when it skips most of them. No API was removed. What to check in an app:
-
To bound a slow page's lag, serve
live.Scripts'newest-frame.jsunder/static/(static.Add("lith/"+live.NewestFrame, js)on the app'sedge.Static), load it after Datastar through the import map, and putdata-newest-frameon<body>. Open the stream withheaders: $_slow ? {'Save-Data': 'on'} : {}at the same URL as before, and setlive.Spec.Slowfromedge.SlowLink(r). The template does all of it. -
The Grafana dashboard and alert rules for lith apps are in
deploy/observabilityat the repository's root and no longer filter on a scrape job: install them again on a host that has the old ones. They match the metric names and the labelapp, and the dashboard has a row forevents. -
Apps opt in to minified JavaScript and CSS by importing the new module
github.com/zweiundeins/lith/minifyand passingedge.Minify(minify.Bytes)toedge.NewStatic, outside development; an app that does not import it serves its files as before and pulls in nothing. The template does. -
A stream whose client falls behind no longer costs renders: a dirty view waits while every stream that watches it has a frame of it waiting, and then sends the frame that waited and the newest a tick later. Nothing to change, unless a test counts a slow stream's frames.
-
events: a new package for event sourcing on the writer. A command callsExecutein itsApply: it reads the stream's state row, which aDeciderfolds from the stream's own events, checks the expected version (Any,NoStream,Exactly, orErrConflict), decides, appends, saves the row and runs the inline projectors (Handle), all in the command's savepoint. Streams are typed by their key,int64orstring, and their events' interface; each event type is defined once withDefine, through a helper of one line per stream that makes the compiler check that the type belongs to it. Events record their time in Unix milliseconds, an actor, a correlation and a causation.AppendandOneappend without a decision;LoadandSinceread as iterators over anysqlite.Querier;Upcastreads an old event type as a new one.Newrefuses a projector that neither handles nor ignores a type of its stream, and duplicate names. ARunnerrebuilds an inline projector whose version changed into copies of the tables it names (Tables), a command per batch, and swaps them in with the last batch, so pages never read a table half built; a decider whoseVersionchanged folds each stream again at its next command. Catch-up projectors (After) run after the commit from a checkpoint, woken by the writer, with the event's position as the idempotency key; an event that keeps failing parks the projector with its error inRunner.Stats.ForgetandPrunedelete events, and positions, fromAUTOINCREMENT, are never handed out again. The tableslith_events,lith_event_stateandlith_projectionsare created by the first command that needs them.events/eventstesttests a decider as given, when, then, and checks a JSON sample of every event type. While a projector rebuilds, new events still reach its live table through its new handlers, in a savepoint whose failure is logged and leaves the command alone; the batches drop the topics they touch, and only the swap touches the projector's topic. A projector whose table is missing failsRunner.Run, and in commands only those on its own stream kind.LoadUntil,SinceUntilandStateAtread a stream, the log and a decider's state as of a position. Through the writer with one inline projector, it appended 18,342 events a second with 200 producers and NORMAL, and rebuilt a projection of 100,000 events in about a second (bench/RESULTS.md). -
eventsandprom:Runner.Statshas the last position committed (Head) and each projector'sLagbehind the last event of its stream kind, kept by the commits without a query, andStore.Statscounts the events committed by stream kind, the conflicts and the refolds.prom.Sourcestakes the runner (Events) and the store (Store) and exports<namespace>_events_*:appended_total{stream},conflicts_total,refolds_total,head_position, and by projectorprojector_position,projector_lag,projector_status(a series per status, 1 for the current one) andprojector_attempts. The lith apps dashboard has an Events row, and two alert rules fire for a projector parked for a minute and for a live catch-up projector more than 1,000 positions behind for 10 minutes. -
events:Prunetakes anevents.Retention:Sincekeeps the events stored from a time on,Newestthe newest so many, and with both an event goes only when both let it go;Limitcaps the events one call takes.CompactisPrunethat closes the books: each stream that loses events keeps one, its last, rewritten in place with its position, version, time and metadata as a snapshot of the stream's state, an event type that the app defines on the stream and that the decider'sEvolveand every projector handle. A rebuild after it makes the tables it made before, as-of reads stay right from the newest snapshot on, and a stream keeps one snapshot at most. It is the one place where a stored event changes; a fourth table,lith_event_kinds, records how far each kind was compacted.Boundsreturns a kind's first and last position, 0 and 0 without events, the newest snapshot first afterCompact.Load,LoadUntil,Since,SinceUntil,StateAtandBoundsread a database whose tables the first command has not made yet as one without events.StateAtrefuses a position before a stream's snapshot. -
writer:Tx.LocalandTx.SetLocalkeep a value per writer, for packages built on it that serve many databases, such as whateventsknows of each. -
writer:Tx.Untouched(fn)runs fn and drops the topics it touched, keeping its changes: for writes that no view reads yet. -
Docs and skill: the guide Event sourcing; the commands guide's event-sourced command and the board example run on
events, and the review checklist has a section on events. -
Website: a ninth demo, Town in time: a town of tiles built together and event-sourced on
events, a stream per tile with a decider and two inline projectors, and a slider that takes each tab back to any position of the log through a history table, the town as of each position a view that every tab there shares. The site runs anevents.Runnerand exportslith_site_events_*. The town's sweep keeps its newest 5,000 events and a snapshot of each tile from before them (Compact), so a rebuild makes the same town, and the badge in the stage's corner says past instead of live while a tab looks back. -
Website: a demo can be featured (
demo.Demo.Featured): its card comes first and takes four cells, so nine cards fill every row on/demos, and on the landing page, which has five columns when the page is wide enough, it takes two. Town in time is featured. -
Template: the Caddy block is
deploy/app.caddy, for/etc/caddy/sites/, anddeploy/observabilityholds the app's blackbox probes and an Alloy file that ships its journal, with slog's level as a label, and its access log to Loki.deploy/setup-host.shinstalls them into the folders a shared host's Prometheus and Alloy load, each only where that service is installed, and reloads Alloy, putting the old file back if Alloy refuses the new one. The CI deploy key is now optional. The guide Metrics and monitoring lists the folders and the labels; an app made from an earlier template can copy the folder and the script. -
lith-new:app_in the template becomes the app's name with-as_, for identifiers such as Alloy's component labels. -
The Grafana dashboard and alert rules for lith apps moved from
site/deploy/observabilitytodeploy/observabilityat the repository's root, with a README on installing them. They no longer filter on a scrape job: they match the metric names and the labelapp, the dashboard also filters byslot, and each alert fires for one app and slot. -
edge:SlowLinkreports whether a request says its client is on a slow link:Save-Data: on, which browsers send when their user asked to save data, and lith's pages send on their stream's request from their own measurement; Chromium's client hints,ECTof slow-2g, 2g or 3g,RTTover 300 ms orDownlinkunder 0.8 Mbit/s, which Chromium sends once a document has asked for them withAccept-CH: ECT, RTT, Downlink(NetworkHints); the query parameternet=slow, which pages put in their stream's URL before they sentSave-Data, for the tabs still open from then. -
live:Spec.Slowmarks a stream to a client on a slow link. It sends each view's newest frame 250 ms or more after the last, about four a second. A view's first frame goes out at once, and so does the frame of a view that sent nothing for 250 ms, which takes the frames that wait along: views that change together still arrive together, and an outcome is not held up. Heartbeats go out as before. On a phone with its CPU slowed four times on DevTools' Slow 4G and 3G presets, a tab of a Game of Life that steps ten times a second kept its main thread 54 to 56% busy instead of 89 to 90%, and its stream sent 2.7 to 2.9 KB/s instead of 5.2 to 6.2. A phone too slow for the page needs it on a fast link too: with the CPU slowed 15 times on an unthrottled link, the tab fell 94 to 98 s behind the board in five minutes without it, while the hub dropped no frame and no write waited, since the browser and the kernel held the frames; with it, the tab stayed within 1.2 s. -
live: a dirty view does not render while every stream that watches it still has a frame of it waiting. The first stream that takes its frame marks the view dirty again, and the next tick renders it as it is then, so a stream that falls behind sends the frame that waited, then the newest a tick later. A stream whose writes block costs no renders until its write deadline ends it, and a view that only slow streams watch renders about four times a second instead of at every tick: for 50 tabs with a view of their own that changes at every tick, 0.4 cores instead of 1.6 to 1.7 on slow streams, and 0.03 to 0.05 instead of 1.4 to 1.7 on streams whose client stopped reading.Stats.RendersDeferredcounts the renders put off, andlith/promexports it as<namespace>_live_renders_deferred_total. A view that one of its streams keeps up with renders at every change, as before. -
The template's page measures its link once as it starts, into
$_slow:navigator.connection'ssaveDataor an effective type of 2g or 3g, or where that is missing, a document whose first byte came more than 300 ms after its request or that came at under 100 KB/s. Its stream's request then sendsSave-Data: on, at the same URL as on a fast link, its documents sendAccept-CH, and its stream handler setsSpec.Slowfromedge.SlowLink. -
live:NewestFrame,newest-frame.jsinScripts, is a Datastar plugin that does in the page what the hub does for a stream: the newest frame of a view wins. A page too slow for its stream falls behind without the server noticing, since the browser holds the frames: Chromium then hands the page 2 MiB of the stream per read, which Datastar applies in one task without a paint, and Firefox keeps them in its parent process. Withdata-newest-frameon<body>, the page holds the stream's patch events, applies only the newest frame of each view among those that arrived together, and after a batch waits half as long as that batch took to apply. A page that keeps up applies every frame. When it drops more than half of its frames for 3 s, it sets$_slowand opens the stream again, which then sendsSave-Data: onat the same URL, so Datastar aborts the old request. Serve it under/static/by its content's hash (edge.Static.Add) and load it after Datastar, through an import map that maps'datastar'; the template does, and its page now has that import map, with the request's CSP nonce. On lith's site, in headless Chromium 154 with its CPU slowed 15 times on an unthrottled link behind Caddy, a tab of the Game of Life asked for a slow stream within 2 s and stayed 0.68 s behind the board at the median over 2 minutes, 1.08 s at most, where it fell 42.6 s behind without the plugin; with every frame costing the page 300 ms more, Chromium 154 and Firefox 158 both asked after 2.8 s and stayed within 0.8 s. -
edgeand the new moduleminify:edge.Minify(fn)hasedge.Staticminify its JavaScript and CSS, the filesNewStaticreads and thoseAddgives it, before it names and precompresses them, so the hashed URL, the ETag and the brotli and gzip copies are all of the minified bytes. A file the function fails on, or shrinks by less than a twentieth, goes out as written, and the function never sees*.min.jsor*.min.css, so a vendored build keeps the bytes its integrity hash pins: tdewolff takes 0.4% off the Datastar build, which a page that pins it would then refuse.github.com/zweiundeins/lith/minifyhas the function,minify.Bytes, on tdewolff/minify; an app opts in by importing it, and one that does not pulls in nothing. The template minifies unless it runs with-dev, whichgo tool task devpasses, so devtools show the files as written; its go.mod requires the module as it requires lith, andlith-newwrites lith's version for both, or replaces both with-lith. The site minifies its CSS bundle and its scripts through it instead of its own code, and with-devserves them as written. The template's stylesheet andnewest-frame.jstake 1,846 bytes under brotli instead of 3,189,NewStatictakes about 6 ms longer at startup, and the template's stripped binary is 644 KiB larger (bench/RESULTS.md).