prefect server in zig

clean up docs: add signal-handling, remove stale notes

- add docs/scratch/signal-handling.md documenting graceful shutdown fix
- delete blocks-implementation.md (blocks fully implemented)
- delete timestamps.md (issues resolved, notes outdated)
- update scratch README with current contents

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

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

+58 -181
+6 -10
docs/scratch/README.md
··· 1 - # scratch pad 1 + # scratch 2 2 3 - capture patterns, observations, and ideas here as we work. periodically review and deduplicate into: 4 - - `CLAUDE.md` files (instructions for claude) 5 - - `docs/*.md` (human documentation) 6 - - `README.md` (project overview) 3 + working notes and patterns. periodically review and deduplicate into `CLAUDE.md` files or `docs/*.md`. 7 4 8 - ## format 5 + ## contents 9 6 10 - one file per topic, date-prefixed if useful: 11 - - `2025-01-20-broker-learnings.md` 12 - - `zig-patterns.md` 13 - - `testing-notes.md` 7 + - [configuration-audit.md](./configuration-audit.md) - config parity with python prefect 8 + - [signal-handling.md](./signal-handling.md) - graceful shutdown patterns (zap/facil.io quirks) 9 + - [zig-patterns.md](./zig-patterns.md) - zig 0.15 idioms used in this codebase
-102
docs/scratch/blocks-implementation.md
··· 1 - # blocks implementation plan 2 - 3 - ## api call sequence 4 - 5 - When user calls `block.save("name")`: 6 - 1. `GET /block_types/slug/{slug}` → 404 if not found 7 - 2. `POST /block_types/` → create type (or `PATCH /block_types/{id}` if exists) 8 - 3. `GET /block_schemas/checksum/{checksum}` → 404 if not found 9 - 4. `POST /block_schemas/` → create schema 10 - 5. `POST /block_documents/` → create document 11 - - if 409 conflict (name exists): `GET` + `PATCH` to update 12 - 13 - When user calls `Block.load("name")`: 14 - 1. `GET /block_types/slug/{slug}/block_documents/name/{name}` → return document with nested schema/type 15 - 16 - ## database tables 17 - 18 - ```sql 19 - block_type ( 20 - id TEXT PRIMARY KEY, 21 - created, updated, 22 - name TEXT NOT NULL, 23 - slug TEXT NOT NULL UNIQUE, 24 - logo_url, documentation_url, description, code_example TEXT, 25 - is_protected INTEGER DEFAULT 0 26 - ) 27 - 28 - block_schema ( 29 - id TEXT PRIMARY KEY, 30 - created, updated, 31 - checksum TEXT NOT NULL, 32 - fields TEXT DEFAULT '{}', -- JSON schema 33 - capabilities TEXT DEFAULT '[]', -- JSON array 34 - version TEXT DEFAULT '1', 35 - block_type_id TEXT FK, 36 - UNIQUE(checksum, version) 37 - ) 38 - 39 - block_document ( 40 - id TEXT PRIMARY KEY, 41 - created, updated, 42 - name TEXT, 43 - data TEXT DEFAULT '{}', -- JSON (encrypted in python, plain for us) 44 - is_anonymous INTEGER DEFAULT 0, 45 - block_type_id TEXT FK, 46 - block_type_name TEXT, -- denormalized 47 - block_schema_id TEXT FK, 48 - UNIQUE(block_type_id, name) 49 - ) 50 - ``` 51 - 52 - ## implementation phases 53 - 54 - ### phase 1: save() support (minimum viable) 55 - - [x] add tables to schema 56 - - [ ] `db/block_types.zig` - insert, getBySlug, update 57 - - [ ] `db/block_schemas.zig` - insert, getByChecksum 58 - - [ ] `db/block_documents.zig` - insert, getById, update 59 - - [ ] `api/block_types.zig`: 60 - - [ ] `GET /block_types/slug/{slug}` 61 - - [ ] `POST /block_types/` 62 - - [ ] `PATCH /block_types/{id}` 63 - - [ ] `api/block_schemas.zig`: 64 - - [ ] `GET /block_schemas/checksum/{checksum}` 65 - - [ ] `POST /block_schemas/` 66 - - [ ] `api/block_documents.zig`: 67 - - [ ] `POST /block_documents/` 68 - - [ ] `PATCH /block_documents/{id}` 69 - - [ ] `GET /block_documents/{id}` 70 - 71 - ### phase 2: load() support 72 - - [ ] `GET /block_types/slug/{slug}/block_documents/name/{name}` 73 - 74 - ### phase 3: filter endpoints 75 - - [ ] `POST /block_types/filter` 76 - - [ ] `POST /block_schemas/filter` 77 - - [ ] `POST /block_documents/filter` 78 - 79 - ### phase 4: nested blocks (if needed) 80 - - [ ] block_schema_reference table 81 - - [ ] block_document_reference table 82 - - [ ] recursive document hydration 83 - 84 - ## test script 85 - 86 - ```python 87 - from prefect.blocks.system import Secret 88 - 89 - # save 90 - secret = Secret(value="my-secret-value") 91 - secret.save("test-secret") 92 - 93 - # load 94 - loaded = Secret.load("test-secret") 95 - print(loaded.get()) 96 - ``` 97 - 98 - ## response formats 99 - 100 - See prefect source for exact JSON shapes: 101 - - `src/prefect/client/schemas/responses.py` 102 - - `src/prefect/server/schemas/core.py`
+52
docs/scratch/signal-handling.md
··· 1 + # signal handling and graceful shutdown 2 + 3 + ## problem 4 + 5 + zap/facil.io returns an error when stopped via `zap.stop()` during SIGTERM handling. this causes `listener.listen()` to bubble up a `ListenError` even though shutdown completed successfully, resulting in exit code 1. 6 + 7 + ## solution 8 + 9 + in `main()`, catch errors from `runServer()`/`runServicesOnly()` and return cleanly (exit 0) if `shutdown_requested` is true: 10 + 11 + ```zig 12 + pub fn main() void { 13 + // ... setup ... 14 + runServer(args.no_services) catch |err| { 15 + if (shutdown_requested) return; // clean shutdown - exit 0 16 + log.err("server", "fatal: {}", .{err}); 17 + std.process.exit(1); 18 + }; 19 + } 20 + ``` 21 + 22 + ## key pattern 23 + 24 + when using signal handlers with external libraries (zap, facil.io), the library's internal state may cause spurious errors during shutdown. use a flag (`shutdown_requested`) to distinguish between: 25 + 1. errors during normal operation → exit 1 26 + 2. errors during requested shutdown → exit 0 27 + 28 + ## signal handler setup (zig 0.15) 29 + 30 + ```zig 31 + var shutdown_requested: bool = false; 32 + 33 + fn signalHandler(sig: c_int) callconv(.c) void { 34 + _ = sig; 35 + if (!shutdown_requested) { 36 + shutdown_requested = true; 37 + zap.stop(); 38 + } 39 + } 40 + 41 + fn setupSignalHandlers() void { 42 + const action = posix.Sigaction{ 43 + .handler = .{ .handler = signalHandler }, 44 + .mask = posix.sigemptyset(), 45 + .flags = 0, 46 + }; 47 + posix.sigaction(posix.SIG.INT, &action, null); 48 + posix.sigaction(posix.SIG.TERM, &action, null); 49 + } 50 + ``` 51 + 52 + note: zig 0.15 uses `.c` (lowercase) for C calling convention, not `.C`.
-69
docs/scratch/timestamps.md
··· 1 - # timestamp handling in prefect 2 - 3 - ## python implementation 4 - 5 - ### storage 6 - - **PostgreSQL**: `TIMESTAMP(timezone=True)` - native timezone-aware 7 - - **SQLite**: `DATETIME()` naive, manually converts to/from UTC 8 - 9 - ### format 10 - all timestamps are UTC timezone-aware. JSON serialization uses ISO 8601: 11 - ``` 12 - 2024-01-22T15:30:45.123456+00:00 13 - ``` 14 - 15 - ### key fields for flow_run 16 - - `expected_start_time` - when the run was originally scheduled 17 - - `next_scheduled_start_time` - **used for scheduling queries** (we're missing this!) 18 - - `start_time` - actual start 19 - - `end_time` - actual end 20 - - `state_timestamp` - when state last changed 21 - 22 - ### get_scheduled_flow_runs query 23 - ```sql 24 - WHERE fr.state_type = 'SCHEDULED' 25 - AND fr.next_scheduled_start_time <= :scheduled_before 26 - ORDER BY fr.next_scheduled_start_time ASC 27 - ``` 28 - 29 - ## our implementation 30 - 31 - ### storage 32 - - **SQLite**: `TEXT` with format `2024-01-22T15:30:45.123456Z` 33 - - using SQLite `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` for defaults 34 - 35 - ### issues 36 - 37 - 1. **missing `next_scheduled_start_time`** - we filter on `expected_start_time` but python uses `next_scheduled_start_time` 38 - 39 - 2. **string comparison is fragile** - we do `expected_start_time <= ?` as string comparison 40 - - our format: `2024-01-22T15:30:45Z` (T separator, Z suffix) 41 - - client format: `2024-01-22 15:30:45+00:00` (space, +00:00 suffix) 42 - - ASCII: `T` (84) > space (32), so comparison fails 43 - 44 - 3. **bandaid fix** - normalizing client timestamps (space→T, +00:00→Z) works but is fragile 45 - 46 - ### proper fix 47 - 48 - 1. add `next_scheduled_start_time` column to `flow_run` 49 - 2. parse timestamps to integers (epoch microseconds) for comparison 50 - 3. or store timestamps as integers in DB for proper numeric comparison 51 - 52 - ## .serve() vs workers 53 - 54 - ### .serve() (Runner) 55 - - creates deployment, starts local polling loop 56 - - calls `POST /deployments/get_scheduled_flow_runs` every N seconds 57 - - executes flows locally in the same process 58 - - **NOT a worker** 59 - 60 - ### workers 61 - - standalone daemon process 62 - - connects to work pools/queues 63 - - work pool workers: `POST /work_pools/{name}/get_scheduled_flow_runs` 64 - - task workers: WebSocket `WS /task_runs/subscriptions/scheduled` 65 - 66 - ### we test 67 - - `test_cron_scheduler` - server-side scheduler creates runs (correct) 68 - - `test_worker_execution` - mislabeled! tests `.serve()` Runner, not a worker 69 - - `test_serve_with_schedule` - verifies deployment has schedule attached (correct)