ACTIAS
blog

A live auction in one file


Every serverless tutorial starts with a counter, and a counter is a fine way to prove that storage exists. It is a bad way to show what is hard.

Here is the hard version. Two people bid on the same lot in the same millisecond, both read a high bid of 4,000, both write 4,200, and one of them silently loses money. Anywhere else that is where you reach for a transaction, a row lock, or a Redis mutex you are fairly sure you got right. On Actias it is where you stop writing coordination code, because a durable object takes one call at a time and the second bid simply reads what the first one wrote.

This walks the whole thing: a lot with its own database file, a live feed into open browser tabs, a durable record that survives a closed tab, and an alarm that ends the auction while nobody is watching. It is one Lua file plus two migrations.

Start a project

actias init auction basic
cd auction

That gives you main.lua, a script.json naming the entry point and which files ship in the bundle, a definitions/ folder your editor reads for autocomplete, and a tests/ folder. The template's main.lua is a hello world; you are about to replace it.

Declare the lot

A durable object is a class you declare and then address by name. Auction("lot-42") is one instance: one live entity, one private SQLite file, one call at a time.

local Auction = object "Auction" {
    migrations = "migrations/Auction",
    publishes = { bid = "public", closed = "public" },

    -- The lot name arrives from a url, so decide here what is allowed
    -- to exist. A refused name leaves nothing behind.
    admit = function(name)
        return name:match("^lot%-%d+$") ~= nil
    end,

    bid = function(state, bidder, amount)
        local high = state.sql:query_one("SELECT MAX(amount) AS amount FROM bids")
        if high and high.amount and amount <= high.amount then
            return { ok = false, high = high.amount }
        end

        state.sql:exec(
            "INSERT INTO bids (bidder, amount, at) VALUES (?, ?, ?)",
            { bidder, amount, state.now() })
        state:publish("bid", { bidder = bidder, amount = amount })
        return { ok = true }
    end,

    top = function(state)
        return state.sql:query(
            "SELECT bidder, amount FROM bids ORDER BY amount DESC LIMIT 5")
    end,
}

Read bid again with the race in mind. It reads the maximum, compares, then writes, which is exactly the pattern that loses updates on a shared database. Here it cannot, because the second call does not start until the first one has returned. There is no lock in that code because there is nothing to lock: one writer is where the object lives, not a mutex it takes.

admit is worth its four lines. The instance name is going to come out of a query string in a moment, and without admit any string a stranger sends creates a lot. With it, lot-42 exists and lot-<anything else> never gets as far as a file.

Give it a schema

The class points at a directory of .sql files. Scaffold the first one:

actias sql Auction create bids

That writes migrations/Auction/0001_bids.sql. Fill it in:

CREATE TABLE bids (
    bidder TEXT    NOT NULL,
    amount INTEGER NOT NULL,
    at     INTEGER NOT NULL
);

Every instance of the class gets its own copy of that schema in its own file. lot-42 and lot-43 are not two rows in a shared table with a lot_id column; they are two databases. Nothing runs at publish time. The files apply at the first call that touches an instance, inside that call's transaction.

Answer requests

on "fetch" (function(request)
    local lot = request.query.lot or "lot-42"

    if request.method == "POST" then
        local body = json.parse(request.body)
        return {
            headers = { ["content-type"] = "application/json" },
            body = json.stringify(Auction(lot):bid(body.bidder, body.amount)),
        }
    end

    return {
        headers = { ["content-type"] = "application/json" },
        body = json.stringify(Auction(lot):top()),
    }
end)

Check it and publish:

actias check
actias publish

check runs the same declaration pass the server runs at publish, then type-checks every file, and prints what your code asked the platform for:

šŸ“œ Project validated!
⚔ Handles events: fetch

Then bid against it:

curl -X POST 'http://localhost:3002/auction/?lot=lot-42' \
  -d '{"bidder":"mira","amount":4200}'
{"ok":true}

Send the same amount again as somebody else and you get {"ok":false,"high":4200}. Send a thousand of them concurrently and you still get exactly one winner per amount, with no retry loop in the handler.

Push it to open tabs

A number that is only correct when you refresh is not an auction. The class already declares two topics; state:publish appends the event to the instance's log in the same transaction as the insert above it, so an event can never describe a write that did not happen.

A connection is the socket's program, declared the same way a class is:

local Floor = connection "Floor" {
    open = function(conn)
        conn:follow(Auction(conn.state.lot), "bid")
        conn:follow(Auction(conn.state.lot), "closed")
    end,

    -- The platform writes the frame, so no vm has to wake to relay one.
    event = "forward",
}

Then upgrade in the same handler that answers HTTP, before the POST branch:

if request.upgrade then
    return request:upgrade(Floor, { lot = lot }, Bidder(request.query.as or "anon"))
end

request:upgrade(Class, seed, identity) seeds conn.state and fixes the identity the socket speaks as for the rest of its life. The client cannot change it afterwards, which matters because identity is what the follow policies check.

The identity here comes off a query string so the example stays one file. In anything real it comes out of a verified session, the same way the POST branch would check who is bidding. admit protects the lot names; nothing yet protects the bidder names.

With event = "forward" each delivery reaches the browser as the platform's own envelope, without running any of your code:

{ "type": "event", "topic": "bid",
  "from": { "id": "Auction/lot-42", "class": "Auction", "name": "lot-42" },
  "data": { "bidder": "mira", "amount": 4200 } }

Open two tabs, bid in one, and the number moves in both.

Survive the tab closing

Connection edges are deliberately weak: at-most-once, no retry, and they die with the socket. That is the right trade for a live number on screen and the wrong one for "you won". Anything that must not be missed follows one hop back, from an object.

local Bidder = object "Bidder" {
    migrations = "migrations/Bidder",

    receives = {
        ["Auction:closed"] = function(state, event)
            state.sql:exec(
                "INSERT INTO results (lot, won, at) VALUES (?, ?, ?)",
                { event.from.name, event.data.winner == state.name and 1 or 0, state.now() })
        end,
    },

    watch = function(state, lot)
        state:follow(Auction(lot), "closed")
    end,

    results = function(state)
        return state.sql:query("SELECT lot, won, at FROM results ORDER BY at DESC LIMIT 20")
    end,
}

Scaffold its table too:

actias sql Bidder create results
CREATE TABLE results (
    lot  TEXT    NOT NULL,
    won  INTEGER NOT NULL,
    at   INTEGER NOT NULL
);

Delivery to an object is at-least-once and it reaches a sleeping one. The publisher's log keeps the event, the edge tracks how far this follower has read, and delivery wakes it. Mira can close her laptop during the auction and the result is waiting in her own file when she comes back, with no catch-up protocol to write. Because it is at-least-once, a receives handler has to tolerate running twice on the same event; if that matters for yours, make the write idempotent.

End it while nobody is looking

Objects schedule their own work. The init hook runs once per instance, before its first call proceeds, and alarm runs when the timer comes due:

hooks = {
    init = function(state)
        state:set_alarm("10m")
    end,

    alarm = function(state)
        local top = state.sql:query_one(
            "SELECT bidder, amount FROM bids ORDER BY amount DESC LIMIT 1")
        state:publish("closed", {
            winner = top and top.bidder,
            amount = top and top.amount,
        })
    end,
}

No cron service, no worker pool, no row somewhere with a closes_at that something else has to poll. The lot knows when it ends because it set the alarm itself, and the event that closes it goes down both kinds of edge: to the open tabs, which see it immediately, and to every Bidder that followed, which see it whenever they next wake.

While you are working

Publishing to try one change gets old. actias dev watches the directory and mirrors every save into a live session:

actias dev

It prints a URL under /_live/<identifier>/<session>/ that reloads on save, so the loop is edit, save, curl. actias tail streams a published script's log lines when you want the deployed one instead.

actias test runs tests/*.lua on the same runtime the platform uses, with kv and secrets faked in memory:

test("the second identical bid loses", function()
    local response = fetch({ method = "GET", path = "/" })
    assert(response.status_code == 200)
end)

Be aware of the edge of that: the fakes cover kv and secrets, not object storage, so tests cover routing and request shape, and the object path wants a running platform. actias dev is the loop for that half.

What the console shows

Open the project's Databases section and the object classes are there beside the SQL databases, Auction with an instance per lot. Click lot-42 and you are reading that instance's own SQLite file, the actual rows the method wrote, not a projection of them. The same is true of queue journals and workflow runs: the console reads the real thing.

Where to go next

  • object for the full class surface: expire, state.store, destroy, and the rest of the hooks.
  • Streams for follow policies and what at-least-once actually promises.
  • connection for the frame handlers this post skipped by using event = "forward".
  • Object placement for why one writer is a lease rather than a lock, and what happens when a worker gets crowded.

The counter still works, by the way. It is four lines and it is still the right answer for a counter. This is what the next question looks like.