prefect server in zig

add horizontal scaling support with kubernetes deployment

single binary with subcommands:
prefect-server # server + services (default)
prefect-server --no-services # API only (horizontal scaling)
prefect-server services # background services only

kubernetes:
- k8s/ manifests for horizontal scaling deployment
- api-deployment: multiple replicas with --no-services
- services-deployment: single instance with `services` subcommand
- postgres + redis for data layer
- kustomize support

ci:
- tangled.org workflow for tests and build

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+527 -41
+22
.tangled/workflows/ci.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - zig 10 + 11 + steps: 12 + - name: check formatting 13 + command: | 14 + zig fmt --check src/ build.zig 15 + 16 + - name: build 17 + command: | 18 + zig build 19 + 20 + - name: run tests 21 + command: | 22 + zig build test --summary all
+4
Dockerfile
··· 46 46 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ 47 47 CMD curl -f http://localhost:4200/api/health || exit 1 48 48 49 + # default: run server with services (single-instance mode) 50 + # for horizontal scaling: 51 + # API servers: /app/prefect-server --no-services 52 + # background services: /app/prefect-server services 49 53 CMD ["/app/prefect-server"]
+11 -18
build.zig
··· 35 35 .optimize = optimize, 36 36 }); 37 37 38 + const imports: []const std.Build.Module.Import = &.{ 39 + .{ .name = "uuid", .module = uuid_dep.module("uuid") }, 40 + .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 41 + .{ .name = "zap", .module = zap.module("zap") }, 42 + .{ .name = "pg", .module = pg.module("pg") }, 43 + .{ .name = "cron", .module = cron.module("cron") }, 44 + .{ .name = "redis", .module = redis_dep.module("redis") }, 45 + }; 46 + 38 47 const exe = b.addExecutable(.{ 39 48 .name = "prefect-server", 40 49 .root_module = b.createModule(.{ 41 50 .root_source_file = b.path("src/main.zig"), 42 51 .target = target, 43 52 .optimize = optimize, 44 - .imports = &.{ 45 - .{ .name = "uuid", .module = uuid_dep.module("uuid") }, 46 - .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 47 - .{ .name = "zap", .module = zap.module("zap") }, 48 - .{ .name = "pg", .module = pg.module("pg") }, 49 - .{ .name = "cron", .module = cron.module("cron") }, 50 - .{ .name = "redis", .module = redis_dep.module("redis") }, 51 - }, 53 + .imports = imports, 52 54 }), 53 55 }); 54 - 55 56 exe.linkLibC(); 56 - 57 57 b.installArtifact(exe); 58 58 59 59 // run step ··· 72 72 .root_source_file = b.path("src/main.zig"), 73 73 .target = target, 74 74 .optimize = optimize, 75 - .imports = &.{ 76 - .{ .name = "uuid", .module = uuid_dep.module("uuid") }, 77 - .{ .name = "zqlite", .module = zqlite.module("zqlite") }, 78 - .{ .name = "zap", .module = zap.module("zap") }, 79 - .{ .name = "pg", .module = pg.module("pg") }, 80 - .{ .name = "cron", .module = cron.module("cron") }, 81 - .{ .name = "redis", .module = redis_dep.module("redis") }, 82 - }, 75 + .imports = imports, 83 76 }), 84 77 }); 85 78 unit_tests.linkLibC();
+26
justfile
··· 127 127 # clean build artifacts 128 128 clean: 129 129 rm -rf zig-out .zig-cache prefect.db 130 + 131 + # --- kubernetes --- 132 + 133 + # deploy to kubernetes 134 + k8s-deploy: 135 + kubectl apply -k k8s/ 136 + 137 + # check kubernetes status 138 + k8s-status: 139 + kubectl -n prefect get pods,svc 140 + 141 + # port-forward to access locally 142 + k8s-forward: 143 + kubectl -n prefect port-forward svc/prefect-server 4200:4200 144 + 145 + # scale API servers 146 + k8s-scale replicas="3": 147 + kubectl -n prefect scale deployment/prefect-api --replicas={{replicas}} 148 + 149 + # view logs 150 + k8s-logs component="api": 151 + kubectl -n prefect logs -l app.kubernetes.io/component={{component}} -f 152 + 153 + # delete kubernetes deployment 154 + k8s-delete: 155 + kubectl delete -k k8s/
+90
k8s/README.md
··· 1 + # kubernetes deployment 2 + 3 + deploy prefect-server to kubernetes with horizontal scaling support. 4 + 5 + ## architecture 6 + 7 + ``` 8 + ┌─────────────────┐ 9 + │ load balancer │ 10 + │ (service) │ 11 + └────────┬────────┘ 12 + 13 + ┌───────────────────┼───────────────────┐ 14 + │ │ │ 15 + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ 16 + │ api-1 │ │ api-2 │ │ api-N │ 17 + │ --no- │ │ --no- │ │ --no- │ 18 + │services │ │services │ │services │ 19 + └────┬────┘ └────┬────┘ └────┬────┘ 20 + │ │ │ 21 + └───────────────────┼───────────────────┘ 22 + 23 + ┌────────────────────────┼────────────────────────┐ 24 + │ │ │ 25 + ┌───▼───┐ ┌───────▼───────┐ ┌────▼────┐ 26 + │ redis │◄───────────│ services │──────────►│postgres │ 27 + │(broker)│ │ (single inst) │ │ (db) │ 28 + └───────┘ └───────────────┘ └─────────┘ 29 + ``` 30 + 31 + ## quick start 32 + 33 + ```bash 34 + # deploy everything 35 + kubectl apply -k k8s/ 36 + 37 + # check status 38 + kubectl -n prefect get pods 39 + 40 + # port-forward to access locally 41 + kubectl -n prefect port-forward svc/prefect-server 4200:4200 42 + 43 + # open http://localhost:4200/api/health 44 + ``` 45 + 46 + ## components 47 + 48 + | component | replicas | description | 49 + |-----------|----------|-------------| 50 + | prefect-api | 2+ | API servers with `--no-services` flag | 51 + | prefect-services | 1 | background services (scheduler, events) | 52 + | postgres | 1 | database (required for scaling) | 53 + | redis | 1 | message broker (required for scaling) | 54 + 55 + ## scaling 56 + 57 + ```bash 58 + # scale API servers 59 + kubectl -n prefect scale deployment/prefect-api --replicas=5 60 + 61 + # background services must stay at 1 replica 62 + ``` 63 + 64 + ## configuration 65 + 66 + edit `configmap.yaml` for environment variables: 67 + - `PREFECT_DATABASE_BACKEND`: sqlite or postgres 68 + - `PREFECT_BROKER_BACKEND`: memory or redis 69 + - `PREFECT_SERVER_LOGGING_LEVEL`: DEBUG, INFO, WARNING, ERROR 70 + 71 + edit `configmap.yaml` secrets section for: 72 + - `PREFECT_DATABASE_URL`: postgres connection string 73 + 74 + ## commands 75 + 76 + single binary with subcommands: 77 + ```bash 78 + prefect-server # server + services (default) 79 + prefect-server --no-services # API only (horizontal scaling) 80 + prefect-server services # background services only 81 + ``` 82 + 83 + ## production considerations 84 + 85 + 1. **postgres**: use a managed database (RDS, Cloud SQL, etc.) 86 + 2. **redis**: use a managed redis (ElastiCache, Memorystore, etc.) 87 + 3. **ingress**: add an ingress controller for external access 88 + 4. **TLS**: configure TLS termination at ingress 89 + 5. **persistence**: add PVC for postgres if using in-cluster 90 + 6. **HPA**: add horizontal pod autoscaler for API servers
+52
k8s/api-deployment.yaml
··· 1 + apiVersion: apps/v1 2 + kind: Deployment 3 + metadata: 4 + name: prefect-api 5 + namespace: prefect 6 + labels: 7 + app.kubernetes.io/name: prefect-server 8 + app.kubernetes.io/component: api 9 + spec: 10 + replicas: 2 11 + selector: 12 + matchLabels: 13 + app.kubernetes.io/name: prefect-server 14 + app.kubernetes.io/component: api 15 + template: 16 + metadata: 17 + labels: 18 + app.kubernetes.io/name: prefect-server 19 + app.kubernetes.io/component: api 20 + spec: 21 + containers: 22 + - name: prefect-api 23 + image: atcr.io/zzstoatzz.io/prefect-server:latest 24 + command: ["/app/prefect-server"] 25 + args: ["--no-services"] 26 + ports: 27 + - containerPort: 4200 28 + name: http 29 + envFrom: 30 + - configMapRef: 31 + name: prefect-server-config 32 + - secretRef: 33 + name: prefect-server-secrets 34 + resources: 35 + requests: 36 + memory: "128Mi" 37 + cpu: "100m" 38 + limits: 39 + memory: "512Mi" 40 + cpu: "500m" 41 + livenessProbe: 42 + httpGet: 43 + path: /api/health 44 + port: 4200 45 + initialDelaySeconds: 5 46 + periodSeconds: 10 47 + readinessProbe: 48 + httpGet: 49 + path: /api/health 50 + port: 4200 51 + initialDelaySeconds: 5 52 + periodSeconds: 5
+28
k8s/configmap.yaml
··· 1 + apiVersion: v1 2 + kind: ConfigMap 3 + metadata: 4 + name: prefect-server-config 5 + namespace: prefect 6 + data: 7 + # database backend: sqlite or postgres 8 + PREFECT_DATABASE_BACKEND: "postgres" 9 + # broker backend: memory or redis 10 + PREFECT_BROKER_BACKEND: "redis" 11 + # server config 12 + PREFECT_SERVER_API_HOST: "0.0.0.0" 13 + PREFECT_SERVER_PORT: "4200" 14 + PREFECT_SERVER_LOGGING_LEVEL: "INFO" 15 + # redis config (used when PREFECT_BROKER_BACKEND=redis) 16 + PREFECT_REDIS_MESSAGING_HOST: "redis.prefect.svc.cluster.local" 17 + PREFECT_REDIS_MESSAGING_PORT: "6379" 18 + --- 19 + apiVersion: v1 20 + kind: Secret 21 + metadata: 22 + name: prefect-server-secrets 23 + namespace: prefect 24 + type: Opaque 25 + stringData: 26 + # postgres connection URL 27 + # format: postgresql://user:password@host:port/database 28 + PREFECT_DATABASE_URL: "postgresql://prefect:prefect@postgres.prefect.svc.cluster.local:5432/prefect"
+13
k8s/kustomization.yaml
··· 1 + apiVersion: kustomize.config.k8s.io/v1beta1 2 + kind: Kustomization 3 + 4 + namespace: prefect 5 + 6 + resources: 7 + - namespace.yaml 8 + - configmap.yaml 9 + - postgres.yaml 10 + - redis.yaml 11 + - api-deployment.yaml 12 + - services-deployment.yaml 13 + - service.yaml
+6
k8s/namespace.yaml
··· 1 + apiVersion: v1 2 + kind: Namespace 3 + metadata: 4 + name: prefect 5 + labels: 6 + app.kubernetes.io/name: prefect-server
+54
k8s/postgres.yaml
··· 1 + apiVersion: apps/v1 2 + kind: Deployment 3 + metadata: 4 + name: postgres 5 + namespace: prefect 6 + labels: 7 + app.kubernetes.io/name: postgres 8 + spec: 9 + replicas: 1 10 + selector: 11 + matchLabels: 12 + app.kubernetes.io/name: postgres 13 + template: 14 + metadata: 15 + labels: 16 + app.kubernetes.io/name: postgres 17 + spec: 18 + containers: 19 + - name: postgres 20 + image: postgres:16-alpine 21 + ports: 22 + - containerPort: 5432 23 + env: 24 + - name: POSTGRES_USER 25 + value: "prefect" 26 + - name: POSTGRES_PASSWORD 27 + value: "prefect" 28 + - name: POSTGRES_DB 29 + value: "prefect" 30 + volumeMounts: 31 + - name: postgres-data 32 + mountPath: /var/lib/postgresql/data 33 + resources: 34 + requests: 35 + memory: "128Mi" 36 + cpu: "100m" 37 + limits: 38 + memory: "512Mi" 39 + cpu: "500m" 40 + volumes: 41 + - name: postgres-data 42 + emptyDir: {} 43 + --- 44 + apiVersion: v1 45 + kind: Service 46 + metadata: 47 + name: postgres 48 + namespace: prefect 49 + spec: 50 + ports: 51 + - port: 5432 52 + targetPort: 5432 53 + selector: 54 + app.kubernetes.io/name: postgres
+41
k8s/redis.yaml
··· 1 + apiVersion: apps/v1 2 + kind: Deployment 3 + metadata: 4 + name: redis 5 + namespace: prefect 6 + labels: 7 + app.kubernetes.io/name: redis 8 + spec: 9 + replicas: 1 10 + selector: 11 + matchLabels: 12 + app.kubernetes.io/name: redis 13 + template: 14 + metadata: 15 + labels: 16 + app.kubernetes.io/name: redis 17 + spec: 18 + containers: 19 + - name: redis 20 + image: redis:7-alpine 21 + ports: 22 + - containerPort: 6379 23 + resources: 24 + requests: 25 + memory: "64Mi" 26 + cpu: "50m" 27 + limits: 28 + memory: "256Mi" 29 + cpu: "200m" 30 + --- 31 + apiVersion: v1 32 + kind: Service 33 + metadata: 34 + name: redis 35 + namespace: prefect 36 + spec: 37 + ports: 38 + - port: 6379 39 + targetPort: 6379 40 + selector: 41 + app.kubernetes.io/name: redis
+17
k8s/service.yaml
··· 1 + apiVersion: v1 2 + kind: Service 3 + metadata: 4 + name: prefect-server 5 + namespace: prefect 6 + labels: 7 + app.kubernetes.io/name: prefect-server 8 + spec: 9 + type: ClusterIP 10 + ports: 11 + - port: 4200 12 + targetPort: 4200 13 + protocol: TCP 14 + name: http 15 + selector: 16 + app.kubernetes.io/name: prefect-server 17 + app.kubernetes.io/component: api
+41
k8s/services-deployment.yaml
··· 1 + apiVersion: apps/v1 2 + kind: Deployment 3 + metadata: 4 + name: prefect-services 5 + namespace: prefect 6 + labels: 7 + app.kubernetes.io/name: prefect-server 8 + app.kubernetes.io/component: services 9 + spec: 10 + # background services should run as a single instance 11 + # to avoid duplicate scheduling, event processing, etc. 12 + replicas: 1 13 + strategy: 14 + type: Recreate 15 + selector: 16 + matchLabels: 17 + app.kubernetes.io/name: prefect-server 18 + app.kubernetes.io/component: services 19 + template: 20 + metadata: 21 + labels: 22 + app.kubernetes.io/name: prefect-server 23 + app.kubernetes.io/component: services 24 + spec: 25 + containers: 26 + - name: prefect-services 27 + image: atcr.io/zzstoatzz.io/prefect-server:latest 28 + command: ["/app/prefect-server"] 29 + args: ["services"] 30 + envFrom: 31 + - configMapRef: 32 + name: prefect-server-config 33 + - secretRef: 34 + name: prefect-server-secrets 35 + resources: 36 + requests: 37 + memory: "64Mi" 38 + cpu: "50m" 39 + limits: 40 + memory: "256Mi" 41 + cpu: "200m"
+122 -23
src/main.zig
··· 24 24 log.debug("server", "{s} {s}", .{ method, path }); 25 25 } 26 26 27 + const Command = enum { server, services_only, help }; 28 + 29 + const Args = struct { 30 + command: Command = .server, 31 + no_services: bool = false, 32 + }; 33 + 34 + fn parseArgs() Args { 35 + var args = Args{}; 36 + var iter = std.process.args(); 37 + _ = iter.next(); // skip program name 38 + 39 + while (iter.next()) |arg| { 40 + if (std.mem.eql(u8, arg, "services")) { 41 + args.command = .services_only; 42 + } else if (std.mem.eql(u8, arg, "--no-services")) { 43 + args.no_services = true; 44 + } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { 45 + args.command = .help; 46 + } 47 + } 48 + return args; 49 + } 50 + 51 + fn printHelp() void { 52 + const help = 53 + \\prefect-server - zig implementation of prefect server 54 + \\ 55 + \\usage: 56 + \\ prefect-server [options] run API server (default: with background services) 57 + \\ prefect-server services run background services only (no API server) 58 + \\ 59 + \\options: 60 + \\ --no-services run API server without background services 61 + \\ --help, -h show this help message 62 + \\ 63 + \\environment variables: 64 + \\ PREFECT_SERVER_PORT port to listen on (default: 4200) 65 + \\ PREFECT_SERVER_API_HOST host to bind to (default: 127.0.0.1) 66 + \\ PREFECT_DATABASE_BACKEND sqlite or postgres (default: sqlite) 67 + \\ PREFECT_DATABASE_PATH sqlite database path (default: prefect.db) 68 + \\ PREFECT_DATABASE_URL postgres connection URL 69 + \\ PREFECT_BROKER_BACKEND memory or redis (default: memory) 70 + \\ PREFECT_REDIS_MESSAGING_HOST redis host (default: localhost) 71 + \\ PREFECT_REDIS_MESSAGING_PORT redis port (default: 6379) 72 + \\ PREFECT_SERVER_LOGGING_LEVEL DEBUG, INFO, WARNING, ERROR (default: INFO) 73 + \\ 74 + \\horizontal scaling: 75 + \\ prefect-server --no-services # run N API server instances 76 + \\ prefect-server services # run 1 background services instance 77 + \\ 78 + ; 79 + const stdout = std.fs.File.stdout(); 80 + stdout.writeAll(help) catch {}; 81 + } 82 + 27 83 pub fn main() !void { 28 84 log.init(); 29 85 86 + const args = parseArgs(); 87 + 88 + switch (args.command) { 89 + .help => { 90 + printHelp(); 91 + return; 92 + }, 93 + .services_only => { 94 + try runServicesOnly(); 95 + }, 96 + .server => { 97 + try runServer(args.no_services); 98 + }, 99 + } 100 + } 101 + 102 + fn runServicesOnly() !void { 103 + log.info("services", "starting background services only...", .{}); 104 + 105 + try initInfra(); 106 + defer deinitInfra(); 107 + 108 + try services.startAll(); 109 + defer services.stopAll(); 110 + 111 + log.info("services", "all services running - press Ctrl+C to stop", .{}); 112 + 113 + // block forever (services run in background threads) 114 + while (true) { 115 + posix.nanosleep(60, 0); 116 + } 117 + } 118 + 119 + fn runServer(no_services: bool) !void { 30 120 const port: u16 = blk: { 31 121 const port_str = posix.getenv("PREFECT_SERVER_PORT") orelse "4200"; 32 122 break :blk std.fmt.parseInt(u16, port_str, 10) catch 4200; 33 123 }; 34 - 35 124 const host = posix.getenv("PREFECT_SERVER_API_HOST") orelse "127.0.0.1"; 36 125 37 - log.info("database", "initializing...", .{}); 38 - try db.init(); 39 - defer db.close(); 40 - log.info("database", "ready", .{}); 41 - 42 - // initialize message broker (memory or redis based on env) 43 - const broker_dialect: broker.Dialect = blk: { 44 - const broker_str = posix.getenv("PREFECT_BROKER_BACKEND") orelse "memory"; 45 - if (std.mem.eql(u8, broker_str, "redis")) { 46 - break :blk .redis; 47 - } 48 - break :blk .memory; 49 - }; 50 - broker.initBroker(std.heap.page_allocator, broker_dialect) catch |err| { 51 - log.err("broker", "failed to initialize: {}", .{err}); 52 - return err; 53 - }; 54 - defer broker.deinitBroker(); 55 - log.info("broker", "ready ({s})", .{@tagName(broker_dialect)}); 126 + try initInfra(); 127 + defer deinitInfra(); 56 128 57 - try services.startAll(); 58 - defer services.stopAll(); 129 + if (!no_services) { 130 + try services.startAll(); 131 + } else { 132 + log.info("server", "running in API-only mode (--no-services)", .{}); 133 + } 134 + defer if (!no_services) services.stopAll(); 59 135 60 136 var listener = zap.HttpListener.init(.{ 61 137 .port = port, ··· 76 152 }); 77 153 } 78 154 155 + fn initInfra() !void { 156 + log.info("database", "initializing...", .{}); 157 + try db.init(); 158 + log.info("database", "ready", .{}); 159 + 160 + const broker_dialect: broker.Dialect = blk: { 161 + const broker_str = posix.getenv("PREFECT_BROKER_BACKEND") orelse "memory"; 162 + if (std.mem.eql(u8, broker_str, "redis")) { 163 + break :blk .redis; 164 + } 165 + break :blk .memory; 166 + }; 167 + broker.initBroker(std.heap.page_allocator, broker_dialect) catch |err| { 168 + log.err("broker", "failed to initialize: {}", .{err}); 169 + return err; 170 + }; 171 + log.info("broker", "ready ({s})", .{@tagName(broker_dialect)}); 172 + } 173 + 174 + fn deinitInfra() void { 175 + broker.deinitBroker(); 176 + db.close(); 177 + } 178 + 79 179 test { 80 - // include tests from submodules 81 180 _ = @import("db/backend.zig"); 82 181 _ = @import("db/dialect.zig"); 83 182 _ = @import("utilities/hashing.zig");