This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

weir / src / api.zig
47 kB 1070 lines
1//! minimal prefect 3.x REST surface over the atproto repo + appview cache. 2//! 3//! the write path is the whole idea: 4//! api mutation -> record into the MST -> signed commit -> CAR on disk 5//! -> appview (burner-redis) updated for reads 6//! 7//! orchestration policy is intentionally absent: every proposed state is 8//! ACCEPTed. this server demonstrates the storage shape, not the rule engine. 9 10const std = @import("std"); 11const httpz = @import("httpz"); 12const zat = @import("zat"); 13 14const repo_mod = @import("repo.zig"); 15const cache_mod = @import("cache.zig"); 16const json_cbor = @import("json_cbor.zig"); 17const util = @import("util.zig"); 18 19const log = std.log.scoped(.api); 20 21const flow_collection = "io.prefect.v0.flow"; 22const flow_run_collection = "io.prefect.v0.flowRun"; 23const task_run_collection = "io.prefect.v0.taskRun"; 24const event_collection = "io.prefect.v0.event"; 25const log_batch_collection = "io.prefect.v0.logBatch"; 26 27pub const Api = struct { 28 allocator: std.mem.Allocator, 29 io: std.Io, 30 repo: *repo_mod.Repo, 31 cache: *cache_mod.Cache, 32 lock: std.Io.Mutex = .init, 33 /// commit cadence: sign+persist every N record writes. 1 = every write 34 /// is durably committed before its response (strictest). higher values 35 /// amortize commit cost at the price of a durability window — the same 36 /// lever as the cloud prototype's delta-flush cadence. 37 commit_every: usize = 1, 38 dirty: usize = 0, 39 40 pub fn handle(self: *Api, req: *httpz.Request, res: *httpz.Response) void { 41 self.dispatch(req, res) catch |err| { 42 log.err("{s} {s}: {}", .{ @tagName(req.method), req.url.path, err }); 43 res.setStatus(.internal_server_error); 44 res.content_type = .JSON; 45 res.body = "{\"detail\":\"internal error\"}"; 46 }; 47 } 48 49 fn dispatch(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 50 var path = req.url.path; 51 if (std.mem.startsWith(u8, path, "/api/")) path = path[4..]; 52 53 res.content_type = .JSON; 54 55 switch (req.method) { 56 .GET => { 57 if (std.mem.eql(u8, path, "/health")) return self.sendRaw(res, .ok, "true"); 58 if (std.mem.eql(u8, path, "/version") or std.mem.eql(u8, path, "/admin/version")) 59 return self.sendRaw(res, .ok, "\"3.7.2\""); 60 if (std.mem.eql(u8, path, "/csrf-token")) return csrfToken(req, res); 61 if (std.mem.eql(u8, path, "/admin/settings")) 62 return self.sendRaw(res, .ok, "{\"server\":{\"analytics_enabled\":false}}"); 63 if (std.mem.eql(u8, path, "/ready")) return self.sendRaw(res, .ok, "true"); 64 65 if (pathId(path, "/flows/")) |id| return self.getEntity(req, res, "flow", id); 66 if (pathId(path, "/flow_runs/")) |id| return self.getFlowRun(req, res, id); 67 if (pathId(path, "/task_runs/")) |id| return self.getTaskRun(req, res, id); 68 69 if (std.mem.eql(u8, path, "/xrpc/com.atproto.sync.getRepo")) return self.getRepoCar(res); 70 if (std.mem.eql(u8, path, "/xrpc/com.atproto.repo.describeRepo")) return self.describeRepo(req, res); 71 if (std.mem.eql(u8, path, "/xrpc/com.atproto.repo.listRecords")) return self.listRecords(req, res); 72 }, 73 .POST => { 74 if (std.mem.eql(u8, path, "/flows/")) return self.createFlow(req, res); 75 if (std.mem.eql(u8, path, "/flow_runs/")) return self.createFlowRun(req, res); 76 if (std.mem.eql(u8, path, "/task_runs/")) return self.createTaskRun(req, res); 77 if (std.mem.eql(u8, path, "/flows/filter")) return self.filterFlows(req, res); 78 if (std.mem.eql(u8, path, "/flow_runs/filter")) return self.filterRuns(req, res, .flow_run, false); 79 if (std.mem.eql(u8, path, "/flow_runs/count")) return self.filterRuns(req, res, .flow_run, true); 80 if (std.mem.eql(u8, path, "/task_runs/filter")) return self.filterRuns(req, res, .task_run, false); 81 if (std.mem.eql(u8, path, "/task_runs/count")) return self.filterRuns(req, res, .task_run, true); 82 if (setStateId(path, "/flow_runs/")) |id| return self.setState(req, res, id, .flow_run); 83 if (setStateId(path, "/task_runs/")) |id| return self.setState(req, res, id, .task_run); 84 if (std.mem.eql(u8, path, "/logs/")) return self.createLogs(req, res); 85 if (std.mem.eql(u8, path, "/events")) { 86 res.setStatus(.no_content); 87 res.body = ""; 88 return; 89 } 90 }, 91 else => {}, 92 } 93 94 res.setStatus(.not_found); 95 res.body = "{\"detail\":\"not found\"}"; 96 } 97 98 fn sendRaw(_: *Api, res: *httpz.Response, status: std.http.Status, body: []const u8) !void { 99 res.setStatus(status); 100 res.body = body; 101 } 102 103 fn csrfToken(req: *httpz.Request, res: *httpz.Response) !void { 104 const client = (try req.query()).get("client") orelse "unknown"; 105 try res.json(.{ 106 .token = "prefect-pds-csrf-token", 107 .client = client, 108 .expiration = "2099-01-01T00:00:00Z", 109 }, .{}); 110 } 111 112 /// "/flows/{id}" -> id, rejecting deeper paths 113 fn pathId(path: []const u8, comptime prefix: []const u8) ?[]const u8 { 114 if (!std.mem.startsWith(u8, path, prefix)) return null; 115 const rest = path[prefix.len..]; 116 if (rest.len == 0 or std.mem.indexOfScalar(u8, rest, '/') != null) return null; 117 return rest; 118 } 119 120 /// "/flow_runs/{id}/set_state" -> id 121 fn setStateId(path: []const u8, comptime prefix: []const u8) ?[]const u8 { 122 if (!std.mem.startsWith(u8, path, prefix)) return null; 123 const rest = path[prefix.len..]; 124 const suffix = "/set_state"; 125 if (!std.mem.endsWith(u8, rest, suffix)) return null; 126 const id = rest[0 .. rest.len - suffix.len]; 127 if (id.len == 0 or std.mem.indexOfScalar(u8, id, '/') != null) return null; 128 return id; 129 } 130 131 // ------------------------------------------------------------------ 132 // rows: the appview's internal storage format. raw_* fields hold 133 // verbatim JSON from the client, spliced back raw into responses. 134 // ------------------------------------------------------------------ 135 136 const RunRow = struct { 137 id: []const u8, 138 created: []const u8, 139 updated: []const u8, 140 name: []const u8, 141 flow_id: []const u8 = "", 142 flow_run_id: ?[]const u8 = null, 143 task_key: []const u8 = "", 144 dynamic_key: []const u8 = "", 145 state_id: []const u8 = "", 146 state_type: []const u8 = "PENDING", 147 state_name: []const u8 = "Pending", 148 state_timestamp: []const u8 = "", 149 state_message: ?[]const u8 = null, 150 raw_state_details: []const u8 = "{}", 151 raw_parameters: []const u8 = "{}", 152 raw_tags: []const u8 = "[]", 153 raw_empirical_policy: []const u8 = "{}", 154 raw_job_variables: []const u8 = "{}", 155 raw_task_inputs: []const u8 = "{}", 156 run_count: i64 = 0, 157 total_run_time: f64 = 0, 158 expected_start_time: ?[]const u8 = null, 159 start_time: ?[]const u8 = null, 160 end_time: ?[]const u8 = null, 161 parent_task_run_id: ?[]const u8 = null, 162 idempotency_key: ?[]const u8 = null, 163 cache_key: ?[]const u8 = null, 164 cache_expiration: ?[]const u8 = null, 165 task_version: ?[]const u8 = null, 166 }; 167 168 fn cacheKey(arena: std.mem.Allocator, kind: []const u8, id: []const u8) ![]const u8 { 169 return std.fmt.allocPrint(arena, "{s}:{s}", .{ kind, id }); 170 } 171 172 fn loadRow(self: *Api, arena: std.mem.Allocator, kind: []const u8, id: []const u8) !?RunRow { 173 const key = try cacheKey(arena, kind, id); 174 const raw = self.cache.get(arena, key) orelse return null; 175 return try std.json.parseFromSliceLeaky(RunRow, arena, raw, .{ .ignore_unknown_fields = true }); 176 } 177 178 fn storeRow(self: *Api, arena: std.mem.Allocator, kind: []const u8, row: RunRow) !void { 179 var out: std.Io.Writer.Allocating = .init(arena); 180 var jw: std.json.Stringify = .{ .writer = &out.writer }; 181 try jw.write(row); 182 const key = try cacheKey(arena, kind, row.id); 183 try self.cache.set(key, out.written()); 184 } 185 186 // ------------------------------------------------------------------ 187 // repo writes 188 // ------------------------------------------------------------------ 189 190 /// build a record from (key, json string) pairs of mixed raw/plain 191 /// values, write it to the repo, and commit. 192 fn writeRecord( 193 self: *Api, 194 arena: std.mem.Allocator, 195 collection: []const u8, 196 rkey: []const u8, 197 record_json: []const u8, 198 ) !void { 199 const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena, record_json, .{}); 200 const cb = try json_cbor.jsonToCbor(arena, parsed); 201 const encoded = try zat.cbor.encodeAlloc(arena, cb); 202 _ = try self.repo.putRecord(collection, rkey, encoded); 203 self.dirty += 1; 204 if (self.dirty >= self.commit_every) { 205 _ = try self.repo.commit(); 206 self.dirty = 0; 207 } 208 } 209 210 // ------------------------------------------------------------------ 211 // flows 212 // ------------------------------------------------------------------ 213 214 fn createFlow(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 215 const arena = req.arena; 216 const body = req.body() orelse return badRequest(res, "body required"); 217 const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch 218 return badRequest(res, "invalid json"); 219 const name = getString(parsed, "name") orelse return badRequest(res, "name required"); 220 const tags = try rawField(arena, parsed, "tags", "[]"); 221 222 self.lock.lock(self.io) catch return error.LockFailed; 223 defer self.lock.unlock(self.io); 224 225 const name_key = try std.fmt.allocPrint(arena, "flow:name:{s}", .{name}); 226 if (self.cache.get(arena, name_key)) |existing_id| { 227 if (self.cache.get(arena, try cacheKey(arena, "flow", existing_id))) |row| { 228 res.setStatus(.ok); 229 res.body = row; 230 return; 231 } 232 } 233 234 var id_buf: [36]u8 = undefined; 235 const id = util.uuid4(self.io, &id_buf); 236 var ts_buf: [32]u8 = undefined; 237 const now = util.timestamp(self.io, &ts_buf); 238 239 const flow_json = try std.fmt.allocPrint(arena, 240 \\{{"id":"{s}","created":"{s}","updated":"{s}","name":"{s}","tags":{s}}} 241 , .{ id, now, now, name, tags }); 242 243 const record_json = try std.fmt.allocPrint(arena, 244 \\{{"$type":"{s}","id":"{s}","created":"{s}","name":"{s}","tags":{s}}} 245 , .{ flow_collection, id, now, name, tags }); 246 try self.writeRecord(arena, flow_collection, id, record_json); 247 248 try self.cache.set(try cacheKey(arena, "flow", id), flow_json); 249 try self.cache.set(name_key, id); 250 try self.cache.sadd("s:flow:all", id); 251 252 res.setStatus(.created); 253 res.body = try arena.dupe(u8, flow_json); 254 } 255 256 fn getEntity(self: *Api, req: *httpz.Request, res: *httpz.Response, kind: []const u8, id: []const u8) !void { 257 const arena = req.arena; 258 if (self.cache.get(arena, try cacheKey(arena, kind, id))) |row| { 259 res.body = row; 260 } else { 261 res.setStatus(.not_found); 262 res.body = "{\"detail\":\"not found\"}"; 263 } 264 } 265 266 // ------------------------------------------------------------------ 267 // flow runs + task runs 268 // ------------------------------------------------------------------ 269 270 const RunKind = enum { flow_run, task_run }; 271 272 fn createFlowRun(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 273 const arena = req.arena; 274 const body = req.body() orelse return badRequest(res, "body required"); 275 const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch 276 return badRequest(res, "invalid json"); 277 278 var id_buf: [36]u8 = undefined; 279 const id = util.uuid4(self.io, &id_buf); 280 var ts_buf: [32]u8 = undefined; 281 const now = try arena.dupe(u8, util.timestamp(self.io, &ts_buf)); 282 283 var row = RunRow{ 284 .id = try arena.dupe(u8, id), 285 .created = now, 286 .updated = now, 287 .name = getString(parsed, "name") orelse try runName(arena, id), 288 .flow_id = getString(parsed, "flow_id") orelse return badRequest(res, "flow_id required"), 289 .raw_parameters = try rawField(arena, parsed, "parameters", "{}"), 290 .raw_tags = try rawField(arena, parsed, "tags", "[]"), 291 .raw_empirical_policy = try rawField(arena, parsed, "empirical_policy", "{}"), 292 .raw_job_variables = try rawField(arena, parsed, "job_variables", "{}"), 293 .parent_task_run_id = getString(parsed, "parent_task_run_id"), 294 .idempotency_key = getString(parsed, "idempotency_key"), 295 }; 296 297 self.lock.lock(self.io) catch return error.LockFailed; 298 defer self.lock.unlock(self.io); 299 300 try self.applyInitialState(arena, &row, parsed, now, .flow_run); 301 302 const record_json = try std.fmt.allocPrint(arena, 303 \\{{"$type":"{s}","id":"{s}","created":"{s}","name":"{s}","flowId":"{s}","parameters":{s},"tags":{s}}} 304 , .{ flow_run_collection, row.id, now, row.name, row.flow_id, row.raw_parameters, row.raw_tags }); 305 try self.writeRecord(arena, flow_run_collection, row.id, record_json); 306 307 try self.storeRow(arena, "flow_run", row); 308 try self.indexRun(arena, "flow_run", row); 309 310 res.setStatus(.created); 311 try self.writeRunResponse(res, .flow_run, row); 312 } 313 314 fn createTaskRun(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 315 const arena = req.arena; 316 const body = req.body() orelse return badRequest(res, "body required"); 317 const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch 318 return badRequest(res, "invalid json"); 319 320 var id_buf: [36]u8 = undefined; 321 const id = util.uuid4(self.io, &id_buf); 322 var ts_buf: [32]u8 = undefined; 323 const now = try arena.dupe(u8, util.timestamp(self.io, &ts_buf)); 324 325 var row = RunRow{ 326 .id = try arena.dupe(u8, id), 327 .created = now, 328 .updated = now, 329 .name = getString(parsed, "name") orelse try runName(arena, id), 330 .flow_run_id = getString(parsed, "flow_run_id"), 331 .task_key = getString(parsed, "task_key") orelse "", 332 .dynamic_key = getString(parsed, "dynamic_key") orelse "", 333 .cache_key = getString(parsed, "cache_key"), 334 .cache_expiration = getString(parsed, "cache_expiration"), 335 .task_version = getString(parsed, "task_version"), 336 .raw_tags = try rawField(arena, parsed, "tags", "[]"), 337 .raw_empirical_policy = try rawField(arena, parsed, "empirical_policy", "{}"), 338 .raw_task_inputs = try rawField(arena, parsed, "task_inputs", "{}"), 339 }; 340 341 self.lock.lock(self.io) catch return error.LockFailed; 342 defer self.lock.unlock(self.io); 343 344 try self.applyInitialState(arena, &row, parsed, now, .task_run); 345 346 const record_json = try std.fmt.allocPrint(arena, 347 \\{{"$type":"{s}","id":"{s}","created":"{s}","name":"{s}","flowRunId":{s},"taskKey":"{s}","dynamicKey":"{s}"}} 348 , .{ 349 task_run_collection, row.id, 350 now, row.name, 351 try jsonOptString(arena, row.flow_run_id), row.task_key, 352 row.dynamic_key, 353 }); 354 try self.writeRecord(arena, task_run_collection, row.id, record_json); 355 356 try self.storeRow(arena, "task_run", row); 357 try self.indexRun(arena, "task_run", row); 358 359 res.setStatus(.created); 360 try self.writeRunResponse(res, .task_run, row); 361 } 362 363 /// secondary indexes: membership sets per state / per parent, mirroring 364 /// the cloud prototype's redis run-state indices. derived, rebuildable. 365 fn indexRun(self: *Api, arena: std.mem.Allocator, kind_str: []const u8, row: RunRow) !void { 366 try self.cache.sadd(try std.fmt.allocPrint(arena, "s:{s}:all", .{kind_str}), row.id); 367 if (row.state_id.len > 0) { 368 try self.cache.sadd(try std.fmt.allocPrint(arena, "s:{s}:state:{s}", .{ kind_str, row.state_type }), row.id); 369 } 370 if (row.flow_id.len > 0) { 371 try self.cache.sadd(try std.fmt.allocPrint(arena, "s:{s}:flow:{s}", .{ kind_str, row.flow_id }), row.id); 372 } 373 if (row.flow_run_id) |frid| { 374 try self.cache.sadd(try std.fmt.allocPrint(arena, "s:{s}:flowrun:{s}", .{ kind_str, frid }), row.id); 375 } 376 } 377 378 fn moveStateIndex(self: *Api, arena: std.mem.Allocator, kind_str: []const u8, id: []const u8, prior: ?[]const u8, new_type: []const u8) !void { 379 if (prior) |p| { 380 try self.cache.srem(try std.fmt.allocPrint(arena, "s:{s}:state:{s}", .{ kind_str, p }), id); 381 } 382 try self.cache.sadd(try std.fmt.allocPrint(arena, "s:{s}:state:{s}", .{ kind_str, new_type }), id); 383 } 384 385 /// runs are created with an optional initial state (the client sends 386 /// Pending). recorded like any other transition: an event. 387 fn applyInitialState(self: *Api, arena: std.mem.Allocator, row: *RunRow, parsed: std.json.Value, now: []const u8, kind: RunKind) !void { 388 const state = parsed.object.get("state") orelse return; 389 if (state != .object) return; 390 391 var sid_buf: [36]u8 = undefined; 392 const state_id = try arena.dupe(u8, util.uuid4(self.io, &sid_buf)); 393 const state_type = if (state.object.get("type")) |t| (if (t == .string) t.string else "PENDING") else "PENDING"; 394 395 row.state_id = state_id; 396 row.state_type = try arena.dupe(u8, state_type); 397 row.state_name = if (state.object.get("name")) |n| 398 (if (n == .string) try arena.dupe(u8, n.string) else defaultStateName(state_type)) 399 else 400 defaultStateName(state_type); 401 row.state_timestamp = now; 402 row.raw_state_details = try rawField(arena, state, "state_details", "{}"); 403 404 try self.writeTransitionEvent(arena, kind, row.*, null); 405 } 406 407 fn setState(self: *Api, req: *httpz.Request, res: *httpz.Response, id: []const u8, kind: RunKind) !void { 408 const arena = req.arena; 409 const body = req.body() orelse return badRequest(res, "body required"); 410 const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch 411 return badRequest(res, "invalid json"); 412 const state = parsed.object.get("state") orelse return badRequest(res, "state required"); 413 if (state != .object) return badRequest(res, "state must be an object"); 414 415 const kind_str = switch (kind) { 416 .flow_run => "flow_run", 417 .task_run => "task_run", 418 }; 419 420 self.lock.lock(self.io) catch return error.LockFailed; 421 defer self.lock.unlock(self.io); 422 423 var row = (try self.loadRow(arena, kind_str, id)) orelse { 424 res.setStatus(.not_found); 425 res.body = "{\"detail\":\"run not found\"}"; 426 return; 427 }; 428 429 var ts_buf: [32]u8 = undefined; 430 const now = try arena.dupe(u8, util.timestamp(self.io, &ts_buf)); 431 var sid_buf: [36]u8 = undefined; 432 const state_id = try arena.dupe(u8, util.uuid4(self.io, &sid_buf)); 433 434 const state_type = if (state.object.get("type")) |t| (if (t == .string) t.string else "PENDING") else "PENDING"; 435 const state_name = if (state.object.get("name")) |n| 436 (if (n == .string and n.string.len > 0) n.string else defaultStateName(state_type)) 437 else 438 defaultStateName(state_type); 439 const message: ?[]const u8 = if (state.object.get("message")) |m| 440 (if (m == .string) m.string else null) 441 else 442 null; 443 444 const prior_type: ?[]const u8 = if (row.state_id.len > 0) 445 try arena.dupe(u8, row.state_type) 446 else 447 null; 448 449 row.state_id = state_id; 450 row.state_type = try arena.dupe(u8, state_type); 451 row.state_name = try arena.dupe(u8, state_name); 452 row.state_timestamp = now; 453 row.state_message = message; 454 row.raw_state_details = try rawField(arena, state, "state_details", "{}"); 455 row.updated = now; 456 if (std.mem.eql(u8, state_type, "RUNNING")) { 457 row.run_count += 1; 458 if (row.start_time == null) row.start_time = now; 459 } 460 if (isTerminal(state_type)) row.end_time = now; 461 462 try self.writeTransitionEvent(arena, kind, row, prior_type); 463 464 try self.storeRow(arena, kind_str, row); 465 try self.moveStateIndex(arena, kind_str, row.id, prior_type, row.state_type); 466 467 // OrchestrationResult: state + ACCEPT 468 var out: std.Io.Writer.Allocating = .init(arena); 469 var jw: std.json.Stringify = .{ .writer = &out.writer }; 470 try jw.beginObject(); 471 try jw.objectField("state"); 472 try writeStateObject(&jw, row, true); 473 try jw.objectField("status"); 474 try jw.write("ACCEPT"); 475 try jw.objectField("details"); 476 try jw.beginObject(); 477 try jw.objectField("type"); 478 try jw.write("accept_details"); 479 try jw.endObject(); 480 try jw.endObject(); 481 482 res.setStatus(.created); 483 res.body = out.written(); 484 } 485 486 /// state transitions are io.prefect.v0.event records — there is no 487 /// state collection. mirrors prefect's own convention: event name 488 /// `prefect.flow-run.{StateName}`, resource labels, state in payload. 489 fn writeTransitionEvent(self: *Api, arena: std.mem.Allocator, kind: RunKind, row: RunRow, prior_type: ?[]const u8) !void { 490 const entity = switch (kind) { 491 .flow_run => "flow-run", 492 .task_run => "task-run", 493 }; 494 const record_json = try std.fmt.allocPrint(arena, 495 \\{{"$type":"{s}","occurred":"{s}","event":"prefect.{s}.{s}","resource":{{"prefect.resource.id":"prefect.{s}.{s}","prefect.resource.name":"{s}"}},"payload":{{"intended":{{"from":{s},"to":"{s}"}},"validated_state":{{"type":"{s}","name":"{s}","timestamp":"{s}","message":{s},"stateDetails":{s}}}}},"eventId":"{s}"}} 496 , .{ 497 event_collection, 498 row.state_timestamp, 499 entity, 500 row.state_name, 501 entity, 502 row.id, 503 row.name, 504 try jsonOptString(arena, prior_type), 505 row.state_type, 506 row.state_type, 507 row.state_name, 508 row.state_timestamp, 509 try jsonOptString(arena, row.state_message), 510 row.raw_state_details, 511 row.state_id, 512 }); 513 514 // rkey: a fresh TID past the commit rev clock so events sort 515 // chronologically within the collection 516 const now_us: u64 = @intCast(@max(0, util.nowMicros(self.io))); 517 var tid = zat.Tid.fromTimestamp(@max(now_us, self.repo.last_rev_us + 1), 1); 518 const rkey = try arena.dupe(u8, tid.str()); 519 try self.writeRecord(arena, event_collection, rkey, record_json); 520 } 521 522 fn getFlowRun(self: *Api, req: *httpz.Request, res: *httpz.Response, id: []const u8) !void { 523 const arena = req.arena; 524 const row = (try self.loadRow(arena, "flow_run", id)) orelse { 525 res.setStatus(.not_found); 526 res.body = "{\"detail\":\"not found\"}"; 527 return; 528 }; 529 try self.writeRunResponse(res, .flow_run, row); 530 } 531 532 fn getTaskRun(self: *Api, req: *httpz.Request, res: *httpz.Response, id: []const u8) !void { 533 const arena = req.arena; 534 const row = (try self.loadRow(arena, "task_run", id)) orelse { 535 res.setStatus(.not_found); 536 res.body = "{\"detail\":\"not found\"}"; 537 return; 538 }; 539 try self.writeRunResponse(res, .task_run, row); 540 } 541 542 // ------------------------------------------------------------------ 543 // response serialization (prefect shapes) 544 // ------------------------------------------------------------------ 545 546 fn writeRunResponse(self: *Api, res: *httpz.Response, kind: RunKind, row: RunRow) !void { 547 _ = self; 548 var out: std.Io.Writer.Allocating = .init(res.arena); 549 var jw: std.json.Stringify = .{ .writer = &out.writer }; 550 try writeRunObject(&jw, kind, row); 551 res.body = out.written(); 552 } 553 554 fn writeRunObject(jw: *std.json.Stringify, kind: RunKind, row: RunRow) !void { 555 try jw.beginObject(); 556 try jw.objectField("id"); 557 try jw.write(row.id); 558 try jw.objectField("created"); 559 try jw.write(row.created); 560 try jw.objectField("updated"); 561 try jw.write(row.updated); 562 try jw.objectField("name"); 563 try jw.write(row.name); 564 565 switch (kind) { 566 .flow_run => { 567 try jw.objectField("flow_id"); 568 try jw.write(row.flow_id); 569 try jw.objectField("parameters"); 570 try writeRaw(jw, row.raw_parameters); 571 try jw.objectField("job_variables"); 572 try writeRaw(jw, row.raw_job_variables); 573 try jw.objectField("deployment_id"); 574 try jw.write(null); 575 try jw.objectField("deployment_version"); 576 try jw.write(null); 577 try jw.objectField("work_queue_name"); 578 try jw.write(null); 579 try jw.objectField("work_queue_id"); 580 try jw.write(null); 581 try jw.objectField("auto_scheduled"); 582 try jw.write(false); 583 try jw.objectField("parent_task_run_id"); 584 try jw.write(row.parent_task_run_id); 585 try jw.objectField("idempotency_key"); 586 try jw.write(row.idempotency_key); 587 }, 588 .task_run => { 589 try jw.objectField("flow_run_id"); 590 try jw.write(row.flow_run_id); 591 try jw.objectField("task_key"); 592 try jw.write(row.task_key); 593 try jw.objectField("dynamic_key"); 594 try jw.write(row.dynamic_key); 595 try jw.objectField("cache_key"); 596 try jw.write(row.cache_key); 597 try jw.objectField("cache_expiration"); 598 try jw.write(row.cache_expiration); 599 try jw.objectField("task_version"); 600 try jw.write(row.task_version); 601 try jw.objectField("task_inputs"); 602 try writeRaw(jw, row.raw_task_inputs); 603 }, 604 } 605 606 try jw.objectField("tags"); 607 try writeRaw(jw, row.raw_tags); 608 try jw.objectField("empirical_policy"); 609 try writeRaw(jw, row.raw_empirical_policy); 610 try jw.objectField("run_count"); 611 try jw.write(row.run_count); 612 try jw.objectField("expected_start_time"); 613 try jw.write(row.expected_start_time); 614 try jw.objectField("start_time"); 615 try jw.write(row.start_time); 616 try jw.objectField("end_time"); 617 try jw.write(row.end_time); 618 try jw.objectField("total_run_time"); 619 try jw.write(row.total_run_time); 620 try jw.objectField("state_type"); 621 try jw.write(if (row.state_id.len > 0) row.state_type else null); 622 try jw.objectField("state_name"); 623 try jw.write(if (row.state_id.len > 0) row.state_name else null); 624 try jw.objectField("state"); 625 if (row.state_id.len > 0) { 626 try writeStateObject(jw, row, kind == .task_run); 627 } else { 628 try jw.write(null); 629 } 630 try jw.endObject(); 631 } 632 633 fn writeStateObject(jw: *std.json.Stringify, row: RunRow, include_data: bool) !void { 634 try jw.beginObject(); 635 try jw.objectField("id"); 636 try jw.write(row.state_id); 637 try jw.objectField("type"); 638 try jw.write(row.state_type); 639 try jw.objectField("name"); 640 try jw.write(row.state_name); 641 try jw.objectField("timestamp"); 642 try jw.write(row.state_timestamp); 643 try jw.objectField("message"); 644 try jw.write(row.state_message); 645 try jw.objectField("state_details"); 646 try writeRaw(jw, row.raw_state_details); 647 if (include_data) { 648 try jw.objectField("data"); 649 try jw.write(null); 650 } 651 try jw.endObject(); 652 } 653 654 // ------------------------------------------------------------------ 655 // filtered reads — served entirely from the appview. candidate ids 656 // come from membership sets; rows sort by ISO timestamp (string order 657 // == time order); limit/offset applied after sort. 658 // ------------------------------------------------------------------ 659 660 fn filterRuns(self: *Api, req: *httpz.Request, res: *httpz.Response, kind: RunKind, count_only: bool) !void { 661 const arena = req.arena; 662 const kind_str = switch (kind) { 663 .flow_run => "flow_run", 664 .task_run => "task_run", 665 }; 666 667 const parsed: std.json.Value = blk: { 668 const body = req.body() orelse break :blk .null; 669 if (body.len == 0) break :blk .null; 670 break :blk std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{}) catch .null; 671 }; 672 673 const runs_filter: std.json.Value = blk: { 674 if (parsed != .object) break :blk .null; 675 const key = switch (kind) { 676 .flow_run => "flow_runs", 677 .task_run => "task_runs", 678 }; 679 break :blk parsed.object.get(key) orelse .null; 680 }; 681 682 // candidates: state filter -> union of state sets, else the all-set 683 var candidates: std.ArrayList([]const u8) = .empty; 684 const state_types = filterAny(runs_filter, "state", "type"); 685 if (state_types) |types| { 686 for (types) |t| { 687 if (t != .string) continue; 688 const members = try self.cache.smembers(arena, try std.fmt.allocPrint(arena, "s:{s}:state:{s}", .{ kind_str, t.string })); 689 try candidates.appendSlice(arena, members); 690 } 691 } else { 692 const members = try self.cache.smembers(arena, try std.fmt.allocPrint(arena, "s:{s}:all", .{kind_str})); 693 try candidates.appendSlice(arena, members); 694 } 695 696 // intersect with flow-run scope for task runs (the engine's 697 // "task runs of this flow run" read) 698 if (kind == .task_run) { 699 if (filterAny(runs_filter, "flow_run_id", null)) |frids| { 700 if (frids.len > 0 and frids[0] == .string) { 701 const scoped = try self.cache.smembers(arena, try std.fmt.allocPrint(arena, "s:task_run:flowrun:{s}", .{frids[0].string})); 702 candidates.items = try intersect(arena, candidates.items, scoped); 703 } 704 } 705 } 706 707 if (count_only) { 708 res.body = try std.fmt.allocPrint(arena, "{d}", .{candidates.items.len}); 709 return; 710 } 711 712 // load rows, sort newest-first, slice 713 var rows: std.ArrayList(RunRow) = .empty; 714 for (candidates.items) |id| { 715 if (try self.loadRow(arena, kind_str, id)) |row| try rows.append(arena, row); 716 } 717 std.mem.sort(RunRow, rows.items, {}, struct { 718 fn newerFirst(_: void, a: RunRow, b: RunRow) bool { 719 return std.mem.order(u8, a.created, b.created) == .gt; 720 } 721 }.newerFirst); 722 723 const offset: usize = if (parsed == .object) 724 if (parsed.object.get("offset")) |o| (if (o == .integer and o.integer > 0) @intCast(o.integer) else 0) else 0 725 else 726 0; 727 const limit: usize = if (parsed == .object) 728 if (parsed.object.get("limit")) |l| (if (l == .integer and l.integer > 0) @intCast(l.integer) else 200) else 200 729 else 730 200; 731 732 const start = @min(offset, rows.items.len); 733 const end = @min(start + limit, rows.items.len); 734 735 var out: std.Io.Writer.Allocating = .init(arena); 736 var jw: std.json.Stringify = .{ .writer = &out.writer }; 737 try jw.beginArray(); 738 for (rows.items[start..end]) |row| try writeRunObject(&jw, kind, row); 739 try jw.endArray(); 740 res.body = out.written(); 741 } 742 743 fn filterFlows(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 744 const arena = req.arena; 745 const ids = try self.cache.smembers(arena, "s:flow:all"); 746 747 const Named = struct { name: []const u8, raw: []const u8 }; 748 var flows: std.ArrayList(Named) = .empty; 749 for (ids) |id| { 750 const raw = self.cache.get(arena, try cacheKey(arena, "flow", id)) orelse continue; 751 const v = std.json.parseFromSliceLeaky(std.json.Value, arena, raw, .{}) catch continue; 752 const name = getString(v, "name") orelse ""; 753 try flows.append(arena, .{ .name = name, .raw = raw }); 754 } 755 std.mem.sort(Named, flows.items, {}, struct { 756 fn byName(_: void, a: Named, b: Named) bool { 757 return std.mem.order(u8, a.name, b.name) == .lt; 758 } 759 }.byName); 760 761 var out: std.Io.Writer.Allocating = .init(arena); 762 var jw: std.json.Stringify = .{ .writer = &out.writer }; 763 try jw.beginArray(); 764 for (flows.items) |f| { 765 try jw.beginWriteRaw(); 766 try jw.writer.writeAll(f.raw); 767 jw.endWriteRaw(); 768 } 769 try jw.endArray(); 770 res.body = out.written(); 771 } 772 773 /// navigate filter json like flow_runs.state.type.any_ -> []Value 774 fn filterAny(filter: std.json.Value, key: []const u8, sub: ?[]const u8) ?[]std.json.Value { 775 if (filter != .object) return null; 776 var node = filter.object.get(key) orelse return null; 777 if (sub) |s| { 778 if (node != .object) return null; 779 node = node.object.get(s) orelse return null; 780 } 781 if (node != .object) return null; 782 const any = node.object.get("any_") orelse return null; 783 if (any != .array) return null; 784 return any.array.items; 785 } 786 787 fn intersect(arena: std.mem.Allocator, a: []const []const u8, b: [][]u8) ![][]const u8 { 788 var keep: std.StringHashMapUnmanaged(void) = .empty; 789 for (b) |m| try keep.put(arena, m, {}); 790 var out: std.ArrayList([]const u8) = .empty; 791 for (a) |m| if (keep.contains(m)) try out.append(arena, m); 792 return out.items; 793 } 794 795 // ------------------------------------------------------------------ 796 // hydration: rebuild the entire appview by replaying the repo. 797 // entities first, then the event log in TID order. this is the 798 // "delete the cache, replay the truth" property, exercised on boot. 799 // ------------------------------------------------------------------ 800 801 pub fn hydrate(self: *Api, arena: std.mem.Allocator) !usize { 802 var applied: usize = 0; 803 804 for (try self.repo.listRecords(arena, flow_collection)) |rec| { 805 const v = zat.cbor.decodeAll(arena, rec.cbor_data) catch continue; 806 const id = rec.key[flow_collection.len + 1 ..]; 807 const name = v.getString("name") orelse continue; 808 const created = v.getString("created") orelse ""; 809 const flow_json = try std.fmt.allocPrint(arena, 810 \\{{"id":"{s}","created":"{s}","updated":"{s}","name":"{s}","tags":{s}}} 811 , .{ id, created, created, name, try cborFieldJson(arena, v, "tags", "[]") }); 812 try self.cache.set(try cacheKey(arena, "flow", id), flow_json); 813 try self.cache.set(try std.fmt.allocPrint(arena, "flow:name:{s}", .{name}), id); 814 try self.cache.sadd("s:flow:all", id); 815 applied += 1; 816 } 817 818 for (try self.repo.listRecords(arena, flow_run_collection)) |rec| { 819 const v = zat.cbor.decodeAll(arena, rec.cbor_data) catch continue; 820 const id = rec.key[flow_run_collection.len + 1 ..]; 821 const row = RunRow{ 822 .id = id, 823 .created = v.getString("created") orelse "", 824 .updated = v.getString("created") orelse "", 825 .name = v.getString("name") orelse "", 826 .flow_id = v.getString("flowId") orelse "", 827 .raw_parameters = try cborFieldJson(arena, v, "parameters", "{}"), 828 .raw_tags = try cborFieldJson(arena, v, "tags", "[]"), 829 }; 830 try self.storeRow(arena, "flow_run", row); 831 try self.indexRun(arena, "flow_run", row); 832 applied += 1; 833 } 834 835 for (try self.repo.listRecords(arena, task_run_collection)) |rec| { 836 const v = zat.cbor.decodeAll(arena, rec.cbor_data) catch continue; 837 const id = rec.key[task_run_collection.len + 1 ..]; 838 const row = RunRow{ 839 .id = id, 840 .created = v.getString("created") orelse "", 841 .updated = v.getString("created") orelse "", 842 .name = v.getString("name") orelse "", 843 .flow_run_id = v.getString("flowRunId"), 844 .task_key = v.getString("taskKey") orelse "", 845 .dynamic_key = v.getString("dynamicKey") orelse "", 846 }; 847 try self.storeRow(arena, "task_run", row); 848 try self.indexRun(arena, "task_run", row); 849 applied += 1; 850 } 851 852 // replay the event log: MST walk order == TID order == time order 853 for (try self.repo.listRecords(arena, event_collection)) |rec| { 854 const v = zat.cbor.decodeAll(arena, rec.cbor_data) catch continue; 855 const event_name = v.getString("event") orelse continue; 856 const resource = v.get("resource") orelse continue; 857 const resource_id = resource.getString("prefect.resource.id") orelse continue; 858 859 const kind_str: []const u8, const kind: RunKind, const prefix: []const u8 = 860 if (std.mem.indexOf(u8, event_name, "flow-run") != null) 861 .{ "flow_run", .flow_run, "prefect.flow-run." } 862 else if (std.mem.indexOf(u8, event_name, "task-run") != null) 863 .{ "task_run", .task_run, "prefect.task-run." } 864 else 865 continue; 866 _ = kind; 867 if (!std.mem.startsWith(u8, resource_id, prefix)) continue; 868 const run_id = resource_id[prefix.len..]; 869 870 const payload = v.get("payload") orelse continue; 871 const state = payload.get("validated_state") orelse continue; 872 const state_type = state.getString("type") orelse continue; 873 const ts = state.getString("timestamp") orelse ""; 874 875 var row = (try self.loadRow(arena, kind_str, run_id)) orelse continue; 876 const prior: ?[]const u8 = if (row.state_id.len > 0) try arena.dupe(u8, row.state_type) else null; 877 878 row.state_id = v.getString("eventId") orelse ""; 879 row.state_type = try arena.dupe(u8, state_type); 880 row.state_name = try arena.dupe(u8, state.getString("name") orelse defaultStateName(state_type)); 881 row.state_timestamp = ts; 882 row.state_message = state.getString("message"); 883 row.raw_state_details = try cborFieldJson(arena, state, "stateDetails", "{}"); 884 row.updated = ts; 885 if (std.mem.eql(u8, state_type, "RUNNING")) { 886 row.run_count += 1; 887 if (row.start_time == null) row.start_time = ts; 888 } 889 if (isTerminal(state_type)) row.end_time = ts; 890 891 try self.storeRow(arena, kind_str, row); 892 try self.moveStateIndex(arena, kind_str, row.id, prior, row.state_type); 893 applied += 1; 894 } 895 896 return applied; 897 } 898 899 /// serialize a cbor map field back to JSON text (or default) 900 fn cborFieldJson(arena: std.mem.Allocator, v: zat.cbor.Value, key: []const u8, default: []const u8) ![]const u8 { 901 const sub = v.get(key) orelse return default; 902 var out: std.Io.Writer.Allocating = .init(arena); 903 var jw: std.json.Stringify = .{ .writer = &out.writer }; 904 try json_cbor.cborToJson(&jw, sub); 905 return out.written(); 906 } 907 908 // ------------------------------------------------------------------ 909 // logs 910 // ------------------------------------------------------------------ 911 912 /// the client already batches logs; one POST = one record 913 fn createLogs(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 914 const arena = req.arena; 915 const body = req.body() orelse return badRequest(res, "body required"); 916 917 self.lock.lock(self.io) catch return error.LockFailed; 918 defer self.lock.unlock(self.io); 919 920 const record_json = try std.fmt.allocPrint(arena, 921 \\{{"$type":"{s}","entries":{s}}} 922 , .{ log_batch_collection, body }); 923 924 const now_us: u64 = @intCast(@max(0, util.nowMicros(self.io))); 925 var tid = zat.Tid.fromTimestamp(@max(now_us, self.repo.last_rev_us + 1), 2); 926 const rkey = try arena.dupe(u8, tid.str()); 927 try self.writeRecord(arena, log_batch_collection, rkey, record_json); 928 929 res.setStatus(.created); 930 res.body = "null"; 931 } 932 933 // ------------------------------------------------------------------ 934 // atproto read surface 935 // ------------------------------------------------------------------ 936 937 fn getRepoCar(self: *Api, res: *httpz.Response) !void { 938 self.lock.lock(self.io) catch return error.LockFailed; 939 defer self.lock.unlock(self.io); 940 941 const car = self.repo.latest_car orelse { 942 res.setStatus(.not_found); 943 res.body = "{\"error\":\"RepoNotFound\",\"message\":\"no commits yet\"}"; 944 return; 945 }; 946 res.content_type = .BINARY; 947 res.header("content-type", "application/vnd.ipld.car"); 948 res.body = try res.arena.dupe(u8, car); 949 } 950 951 fn describeRepo(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 952 _ = req; 953 self.lock.lock(self.io) catch return error.LockFailed; 954 defer self.lock.unlock(self.io); 955 956 try res.json(.{ 957 .did = self.repo.did, 958 .didDoc = .{}, 959 .handle = "prefect.local", 960 .collections = &[_][]const u8{ 961 flow_collection, 962 flow_run_collection, 963 task_run_collection, 964 event_collection, 965 log_batch_collection, 966 }, 967 .handleIsCorrect = false, 968 }, .{}); 969 } 970 971 fn listRecords(self: *Api, req: *httpz.Request, res: *httpz.Response) !void { 972 const arena = req.arena; 973 const collection = (try req.query()).get("collection") orelse 974 return badRequest(res, "collection required"); 975 976 self.lock.lock(self.io) catch return error.LockFailed; 977 defer self.lock.unlock(self.io); 978 979 const records = try self.repo.listRecords(arena, collection); 980 981 var out: std.Io.Writer.Allocating = .init(arena); 982 var jw: std.json.Stringify = .{ .writer = &out.writer }; 983 try jw.beginObject(); 984 try jw.objectField("records"); 985 try jw.beginArray(); 986 for (records) |rec| { 987 const decoded = zat.cbor.decodeAll(arena, rec.cbor_data) catch continue; 988 try jw.beginObject(); 989 try jw.objectField("uri"); 990 const uri = try std.fmt.allocPrint(arena, "at://{s}/{s}", .{ self.repo.did, rec.key }); 991 try jw.write(uri); 992 try jw.objectField("value"); 993 try json_cbor.cborToJson(&jw, decoded); 994 try jw.endObject(); 995 } 996 try jw.endArray(); 997 try jw.endObject(); 998 999 res.body = out.written(); 1000 } 1001 1002 // ------------------------------------------------------------------ 1003 // helpers 1004 // ------------------------------------------------------------------ 1005 1006 fn badRequest(res: *httpz.Response, msg: []const u8) !void { 1007 res.setStatus(.unprocessable_entity); 1008 var out: std.Io.Writer.Allocating = .init(res.arena); 1009 var jw: std.json.Stringify = .{ .writer = &out.writer }; 1010 try jw.beginObject(); 1011 try jw.objectField("detail"); 1012 try jw.write(msg); 1013 try jw.endObject(); 1014 res.body = out.written(); 1015 } 1016 1017 fn getString(v: std.json.Value, key: []const u8) ?[]const u8 { 1018 if (v != .object) return null; 1019 const field = v.object.get(key) orelse return null; 1020 return if (field == .string) field.string else null; 1021 } 1022 1023 /// re-serialize a json field verbatim (or default if absent/null) 1024 fn rawField(arena: std.mem.Allocator, v: std.json.Value, key: []const u8, default: []const u8) ![]const u8 { 1025 if (v != .object) return default; 1026 const field = v.object.get(key) orelse return default; 1027 if (field == .null) return default; 1028 var out: std.Io.Writer.Allocating = .init(arena); 1029 var jw: std.json.Stringify = .{ .writer = &out.writer }; 1030 try jw.write(field); 1031 return out.written(); 1032 } 1033 1034 fn jsonOptString(arena: std.mem.Allocator, s: ?[]const u8) ![]const u8 { 1035 const v = s orelse return "null"; 1036 return std.fmt.allocPrint(arena, "\"{s}\"", .{v}); 1037 } 1038 1039 fn writeRaw(jw: *std.json.Stringify, raw: []const u8) !void { 1040 try jw.beginWriteRaw(); 1041 try jw.writer.writeAll(if (raw.len > 0) raw else "null"); 1042 jw.endWriteRaw(); 1043 } 1044 1045 fn runName(arena: std.mem.Allocator, id: []const u8) ![]const u8 { 1046 return std.fmt.allocPrint(arena, "run-{s}", .{id[0..8]}); 1047 } 1048 1049 fn defaultStateName(state_type: []const u8) []const u8 { 1050 const names = std.StaticStringMap([]const u8).initComptime(.{ 1051 .{ "PENDING", "Pending" }, 1052 .{ "RUNNING", "Running" }, 1053 .{ "COMPLETED", "Completed" }, 1054 .{ "FAILED", "Failed" }, 1055 .{ "SCHEDULED", "Scheduled" }, 1056 .{ "CANCELLED", "Cancelled" }, 1057 .{ "CANCELLING", "Cancelling" }, 1058 .{ "CRASHED", "Crashed" }, 1059 .{ "PAUSED", "Paused" }, 1060 }); 1061 return names.get(state_type) orelse "Unknown"; 1062 } 1063 1064 fn isTerminal(state_type: []const u8) bool { 1065 return std.mem.eql(u8, state_type, "COMPLETED") or 1066 std.mem.eql(u8, state_type, "FAILED") or 1067 std.mem.eql(u8, state_type, "CRASHED") or 1068 std.mem.eql(u8, state_type, "CANCELLED"); 1069 } 1070};