Zig 0.17.0

Scheduled jobs

Some work is nobody's request: releasing what expired, sending what was queued, cleaning up. A feature declares it as a job, and the server runs it on a timer.

The code on this page is from Bookings, the example app in examples/bookings (source). A new booking holds its room for ten minutes; if nobody confirms it, the time has to become free again, whether or not anyone is looking.

A job

A feature lists its jobs in its mod.zig:

zig
/// Scheduled by `server.mountFeatures`: every half minute, the holds that
/// ran out are released; once an hour, old bookings are deleted when
/// BOOKINGS_KEEP_DAYS asks for it.
pub const jobs = .{
    spider.every(expiry.every_ms, expiry.expireHolds),
    spider.every(expiry.cleanup_every_ms, expiry.removeOld),
};

spider.every takes how often, in milliseconds, and the function to run. server.mountFeatures in src/main.zig finds the list and starts the timers when the server starts listening. The first run comes one interval after that.

Bookings has two. This is the first:

zig
/// How often the holds are looked at.
pub const every_ms = 30_000;

/// Releases the holds that ran out and tells the pages showing their days.
/// It runs on a thread of its own, outside any request, so it brings its
/// own memory and reports its own errors: there is nobody to return one to.
pub fn expireHolds(hub: *spider.Hub) void {
    var arena_state: std.heap.ArenaAllocator = .init(std.heap.smp_allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const days = repository.expireStale(arena) catch |err| {
        std.log.warn("bookings: could not release the holds that ran out: {s}", .{@errorName(err)});
        return;
    };
    for (days) |day| {
        const channel = std.fmt.allocPrint(arena, "day:{s}", .{day}) catch continue;
        hub.emitTo(channel, "changed", .{ .day = day });
    }
    if (days.len > 0) std.log.info("bookings: {d} hold(s) ran out and were released", .{days.len});
}

Three things are different from a handler:

  • No request. There is no c, so no c.arena: the job makes its own arena and frees it when it is done.
  • Nobody to answer. The function returns nothing. It catches its own errors and writes them to the log.
  • Its own thread. The job runs beside the requests, at the same time as them. What it shares with them must be safe to share: the database pool is, a global variable it writes to is not.

What it does have is the hub: the same one handlers reach with c.sseHub(). The job above uses it to tell the pages showing a day that the day changed.

The query the job runs is in the repository, like any other:

zig
/// Marks as expired every hold that ran out, and gives back the days they
/// were on ("2026-10-12"), one per booking: the pages showing those days
/// have something new to draw.
pub fn expireStale(arena: std.mem.Allocator) ![]const []const u8 {
    const Row = struct { day: []const u8 };
    const rows = try db.query(Row, arena,
        \\UPDATE bookings SET state = 'expired'
        \\WHERE state = 'held' AND held_until < now()
        \\RETURNING day::text AS day
    , .{});
    const days = try arena.alloc([]const u8, rows.len);
    for (rows, days) |row, *day| day.* = row.day;
    return days;
}

Do not depend on the timer alone

A job runs every so often, not at the moment something happens. Between a hold running out and the job's next visit, up to half a minute passes. The app must be right in that gap too:

  • The page of a booking treats a hold that ran out as expired, whatever the row still says.
  • Making a booking releases a dead hold of the same room in the same transaction, so nobody is refused because of one. PostgreSQL shows it.

The job is what tidies up when nobody does either.

Once, when the app starts

A feature can also say what it needs before the first request. pub fn boot in its mod.zig runs once, when the server starts listening:

zig
/// Run by `server.mountFeatures` before the first request: how mail leaves
/// and who else hears about a confirmed booking.
pub const boot = notify.boot;
zig
/// How the mail leaves. Until `boot` runs it only writes to the log.
pub var mailer: spider.mail.Mailer = .{ .backend = .{ .log = .{} } };

/// The address told about a confirmed booking, or null to tell nobody.
pub var webhook_url: ?[]const u8 = null;

/// Read once, when the server starts (`server.mountFeatures` calls it):
/// MAIL_TRANSPORT and its key decide how mail is sent (the log, when
/// nothing is set), BOOKINGS_WEBHOOK_URL who else is told.
pub fn boot(_: spider.Boot) !void {
    mailer = try spider.mail.Mailer.fromEnv();
    webhook_url = spider.env.get("BOOKINGS_WEBHOOK_URL");
}

boot receives a spider.Boot with an allocator that lives as long as the process and the server's io. When it returns an error the server does not start, and the error says why: a missing setting is found when the app is deployed, not by the first visitor who needs it.

Jobs of a module

A module declares jobs and a boot the same way, in its root file, and may add a shutdown. The modules boot before the features, in the order their requirements give, and server.deinit() runs their shutdown after the jobs stopped: Writing a module.

Testing a job

The timer is not what needs a test: the work is. Call the function the job calls, after putting the data in the state it should find:

zig
test "a hold that ran out gives the time back" {
    _ = try app_test.running();
    var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const birch = try roomId(arena, "Birch");
    const day = "2031-04-07";

    const made = try bookings.create(arena, booking(birch, day, "16:00", "17:00"));
    // Eleven minutes pass.
    try spider.pg.query(void, arena, "UPDATE bookings SET held_until = now() - interval '1 minute' WHERE id = $1", .{made.id});

    try std.testing.expect(!try bookings.confirm(arena, made.id)); // too late
    // Someone else books the same time: the dead hold steps aside in the
    // same transaction that makes the new booking.
    const next = try bookings.create(arena, booking(birch, day, "16:00", "17:00"));
    try std.testing.expectEqual(features.bookings.model.State.expired, (try bookings.find(arena, made.id)).?.state);
    try std.testing.expectEqual(features.bookings.model.State.held, next.state);

    // And the sweep that will run on a timer finds the ones nobody replaced.
    const other = try bookings.create(arena, booking(birch, day, "18:00", "19:00"));
    try spider.pg.query(void, arena, "UPDATE bookings SET held_until = now() - interval '1 minute' WHERE id = $1", .{other.id});
    const days = try bookings.expireStale(arena);
    try std.testing.expect(days.len >= 1);
    var found = false;
    for (days) |expired_day| {
        if (std.mem.eql(u8, expired_day, day)) found = true;
    }
    try std.testing.expect(found);
    // Nothing left to release.
    try std.testing.expectEqual(@as(usize, 0), (try bookings.expireStale(arena)).len);
    try std.testing.expectEqual(features.bookings.model.State.expired, (try bookings.find(arena, other.id)).?.state);
}