- Description
- Requirements
- Usage (Zig)
- Usage (MicroZig)
- Schedules
- Threading and lifetimes
- Usage (C bindings)
- Examples
- Tests
- Roadtrip
- Contributing
- License
libnarnia is a job-scheduling library (cron-like recurring jobs) for Zig 0.16.0, with C bindings.
Note
The cron syntax is not implemented yet.
This will fetch from the main branch:
zig fetch --save git+https://github.com/utox39/libnarnia.gitIf you want to use Zig master, use the zig-master branch:
zig fetch --save git+https://github.com/utox39/libnarnia.git#zig-masterIn build.zig:
const libnarnia = b.dependency("libnarnia", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("libnarnia", libnarnia.module("libnarnia"));Or:
const libnarnia = b.dependency("libnarnia", .{
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "my_project",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "libnarnia", .module = libnarnia.module("libnarnia") },
},
}),
});const std = @import("std");
const libnarnia = @import("libnarnia");
fn tick(io: std.Io, label: []const u8) void {
const now = std.Io.Timestamp.now(io, .real).toSeconds();
const fields = libnarnia.schedule.CalendarFields.fromEpochSeconds(now);
std.debug.print("{f} - {s}\n", .{ fields, label });
}
pub fn main(init: std.process.Init) !void {
var gpa: std.heap.DebugAllocator(.{ .thread_safe = true }) = .init;
defer _ = gpa.deinit();
// `.concurrent` gives every job its own timer task; `.min_heap` drives them
// all from one shared loop, which scales better with many jobs and makes
// `peek()` meaningful.
var scheduler = libnarnia.Scheduler.init(init.io, gpa.allocator(), .concurrent);
defer scheduler.deinit();
const now = std.Io.Timestamp.now(init.io, .real).toSeconds();
// The callback and its arguments are passed separately: the tuple is
// copied into the scheduler, so it outlives this scope.
const job_id = try scheduler.add(
.{ .every_n_seconds = .{ .n = 5 } },
"tick", // optional; only used in log messages about failing callbacks
tick,
.{ init.io, "every 5 seconds" },
now, // the first run is computed from this instant, strictly after it
);
// Launches every job that isn't already running, then returns immediately.
// Nothing fires before this; a job added *after* it is picked up on its
// own, so one call is enough.
try scheduler.start();
try std.Io.sleep(init.io, std.Io.Duration.fromSeconds(16), .real);
// Blocks until the job's timer loop and its in-flight callbacks have
// stopped, then frees the captured arguments.
_ = scheduler.remove(job_id);
}
A long-running program would park in scheduler.wait() instead of sleeping;
it returns once another thread calls scheduler.stop().
Full API documentation: libnarnia-docs
Or:
cd path/to/libnarnia
zig build docs
python3 -m http.server 8000 -d zig-out/docsAnd go to http://localhost:8000
Note
MicroZig requires Zig master.
zig fetch --save git+https://github.com/utox39/libnarnia.git#zig-masterNote
This guide uses the Raspberry Pi Pico. See MicroZig - Getting Started for more info.
In build.zig:
const std = @import("std");
const microzig = @import("microzig");
const MicroBuild = microzig.MicroBuild(
.{
.rp2xxx = true,
},
);
pub fn build(b: *std.Build) void {
const mz_dep = b.dependency("microzig", .{});
const mb = MicroBuild.init(b, mz_dep) orelse return;
const libnarnia = b.dependency("libnarnia", .{
.optimize = .ReleaseSmall,
});
const firmware = mb.add_firmware(.{
.name = "blinky-with-libnarnia",
.target = mb.ports.rp2xxx.boards.raspberrypi.pico,
.optimize = .ReleaseSmall,
.root_source_file = b.path("src/main.zig"),
});
firmware.add_app_import("libnarnia", libnarnia.module("libnarnia"), .{});
mb.install_firmware(firmware, .{});
}const std = @import("std");
const microzig = @import("microzig");
const rp2xxx = microzig.hal;
const time = rp2xxx.time;
const narnia = @import("libnarnia");
const panic = microzig.panic;
const std_options = microzig.std_options(.{});
comptime {
_ = microzig.export_startup();
}
const pin_config = rp2xxx.pins.GlobalConfiguration{
.GPIO25 = .{
.name = "internal_led",
.direction = .out,
},
.GPIO2 = .{
.name = "green_led",
.direction = .out,
},
.GPIO3 = .{
.name = "yellow_led",
.direction = .out,
},
.GPIO4 = .{
.name = "red_led",
.direction = .out,
},
};
fn sleepSec(sec: i64) void {
time.sleep_ms(@as(u32, @intCast(sec)) * 1000);
}
fn timeSinceBoot() i64 {
return @intCast(time.get_time_since_boot().to_us() / std.time.us_per_s);
}
fn blinkLedCallback(ctx: ?*anyopaque) void {
const led: *const rp2xxx.gpio.Pin = @ptrCast(@alignCast(ctx.?));
led.toggle();
time.sleep_ms(1000);
led.toggle();
}
pub fn main() !void {
var scheduler: narnia.SchedulerMicro.SchedulerMicro(4, false) = .init;
const now = timeSinceBoot();
const pins = pin_config.apply();
var internal = pins.internal_led;
var green = pins.green_led;
var yellow = pins.yellow_led;
var red = pins.red_led;
_ = try scheduler.add(
.{ .every_n_seconds = .{ .n = 10 } },
"blink internal led",
blinkLedCallback,
&internal,
now,
);
_ = try scheduler.add(
.{ .every_n_seconds = .{ .n = 1 } },
"blink green led",
blinkLedCallback,
&green,
now,
);
_ = try scheduler.add(
.{ .every_n_seconds = .{ .n = 2 } },
"blink yellow led",
blinkLedCallback,
&yellow,
now,
);
_ = try scheduler.add(
.{ .every_n_seconds = .{ .n = 3 } },
"blink red led",
blinkLedCallback,
&red,
now,
);
scheduler.start(.{ .now = timeSinceBoot, .sleep = sleepSec });
}Tested with MicroZig v0.17.10
All timestamps are unix seconds, UTC. Times before 1970 are not supported.
| Variant | Fires | Example |
|---|---|---|
every_n_seconds |
every N seconds, epoch-aligned | .{ .every_n_seconds = .{ .n = 30 } } |
every_n_minutes |
every N minutes, epoch-aligned | .{ .every_n_minutes = .{ .n = 15 } } |
hourly |
every hour at MM:SS | .{ .hourly = .{ .minutes = 30 } } |
daily |
every day at HH:MM:SS | .{ .daily = .{ .hour = 9, .minute = 0, .second = 0 } } |
weekly |
every week on a weekday | .{ .weekly = .{ .day = .MONDAY, .hour = 9, .minute = 0 } } |
monthly |
every month on a day | .{ .monthly = .{ .day = .{ .day = 1 }, .minute = 0 } } |
yearly |
every year on a month/day | .{ .yearly = .{ .month = .jan, .day = .{ .day = 1 }, .minute = 0 } } |
monthly and yearly take a DayOfMonth: either .{ .day = 1..31 } or
.last_day, which resolves to whatever the month's last day is (28/29/30/31).
Three behaviours worth knowing before you rely on one:
- The next fire time is strictly after the reference instant. Handing
adda timestamp that is itself a fire instant schedules the following occurrence, never that same second. - Missed occurrences are dropped, not replayed. A job that starts out
overdue (a stale
now, a latestart(), a relaunch afterstop()) or that falls behind a callback slower than its own interval skips the elapsed occurrences un-fired and resumes at the next future one. - A month too short for the requested day is skipped, not clamped.
.{ .day = 31 }does not fire in April, and February 29 only fires on leap years. Use.last_dayif you want the end of every month.
Job callbacks run on the scheduler's internal thread pool, never on the thread
that called start().
add,remove,start,count,peekandwaitare thread-safe.stopanddeinitare not. Both require exclusive access: they walk the queue with the lock released (cancelling a job blocks until its callbacks drain, and holding the lock across that would deadlock against any callback calling back into the scheduler), so neither may race anaddorremove.- A callback must never remove its own job.
removewaits for the job's callbacks to finish, so the callback would wait on itself and deadlock. Removing a different job from inside a callback is fine, as isadd: a job added from a callback starts firing on its own, like one added anywhere else. - A callback can overlap with itself. Each firing is dispatched fire-and-forget so a slow callback never delays the next occurrence, so a callback outlasting its own interval runs more than once at a time, every invocation sharing the one copy of the arguments the job was registered with. Callbacks holding mutable state must synchronise it themselves.
- The scheduler owns the arguments tuple it copied in
add, and frees it onceremoveordeinithas proven the callbacks have stopped. Anything the tuple only points at is still yours to keep alive. - A callback returning an error doesn't stop its job: the error is logged and the timer loop keeps running.
zig buildThat installs, under zig-out/:
zig-out/include/narnia.h # the C header
zig-out/lib/libnarnia.a # static library
zig-out/lib/libnarnia.dylib # shared library (libnarnia.so on Linux)
zig-out/bin/libnarnia # the Zig demo, `zig build run`
zig-out/bin/narnia-example # the C example, `zig build example`
On Windows the libraries are narnia.lib and narnia.dll — Zig drops the
lib prefix there.
Override the install prefix with --prefix, e.g. zig build --prefix /usr/local.
Static:
Name the ar archive directly, so the linker can't prefer the shared library sitting next to it:
zig cc my_app.c -I zig-out/include zig-out/lib/libnarnia.a -o my_appmacOS static caveat: link the archive with zig cc, not Apple's cc. Zig
0.16 writes archive members that Apple's ld rejects with "64-bit mach-o not
8-byte aligned", in every optimize mode. Zig's own linker handles it, which is
also why zig build example works. On Linux, plain cc links the archive
fine.
Shared:
cc my_app.c -I zig-out/include -L zig-out/lib -lnarnia -lpthread \
-Wl,-rpath,"$PWD/zig-out/lib" -o my_appThe -rpath is not optional on macOS: the dylib's install name is
@rpath/libnarnia.dylib, which DYLD_LIBRARY_PATH does not satisfy. On Linux
you can drop it and set LD_LIBRARY_PATH=$PWD/zig-out/lib at runtime instead.
The scheduler runs jobs on an internal thread pool, so -lpthread is required
on Linux. It is a no-op on macOS, where pthreads live in libc.
Pass a target triple through to the build; the resulting library is linked the same way from that platform's toolchain.
zig build -Dtarget=x86_64-linux-gnu -Doptimize=ReleaseFastThere is no generated .pc or CMake package config. Point your build system at
zig-out/include and zig-out/lib directly — in CMake:
add_library(narnia STATIC IMPORTED)
set_target_properties(narnia PROPERTIES
IMPORTED_LOCATION "${NARNIA_ROOT}/zig-out/lib/libnarnia.a"
INTERFACE_INCLUDE_DIRECTORIES "${NARNIA_ROOT}/zig-out/include")
target_link_libraries(my_app PRIVATE narnia Threads::Threads)See: include/narnia.h
Read include/narnia.h for the API. Two rules the header spells out and the
compiler cannot enforce:
narnia_scheduler_stopandnarnia_scheduler_destroyrequire exclusive access — they must not race any other call on the same scheduler.- A job's callback must never remove its own job; that deadlocks. Removing a different job, or adding one, is fine.
See: src/main.zig
Run it with:
zig build runSee: examples/example.c
examples/example.c is a complete program built and linked by the same build.zig.
Run it with:
zig build examplezig build test --summary allThe concurrent and the min_heap scheduler tests wait on real wall-clock time
rather than a mocked one (for now).
The two files that import nothing can also be tested on their own, which is much faster while iterating on the time math:
zig test src/Scheduler.zig
zig test src/schedule.zig
zig test src/schedule.zig --test-filter "monthly" # a subset, by namesrc/c_api.zig cannot be run with zig test src/c_api.zig because it imports
the libnarnia module, which only the build graph supplies.
zig build c-test- Add cron syntax support
- Experiments with MicroZig
- Add more C API tests
- Add an on_error policy
Please see CONTRIBUTING. Thanks!
MIT License. See: LICENSE