fairchild 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fairchild/__init__.py CHANGED
@@ -1,11 +1,9 @@
1
1
  from fairchild.task import task
2
2
  from fairchild.job import Job
3
- from fairchild.record import Record
4
3
  from fairchild.fairchild import Fairchild
5
4
 
6
5
  __all__ = [
7
6
  "task",
8
7
  "Job",
9
- "Record",
10
8
  "Fairchild",
11
9
  ]
fairchild/cli.py CHANGED
@@ -162,19 +162,11 @@ async def _run_workers(queue_config: dict[str, int]):
162
162
  @cli.command()
163
163
  @click.option("--host", "-h", default="127.0.0.1", help="Host to bind to")
164
164
  @click.option("--port", "-p", default=4000, help="Port to bind to")
165
- @click.option(
166
- "--import",
167
- "-i",
168
- "imports",
169
- multiple=True,
170
- help='Module to import for task registration, e.g., "myapp.tasks"',
171
- )
172
- def ui(host: str, port: int, imports: tuple[str, ...]):
173
- """Start the web UI dashboard."""
174
- # Import task modules
175
- for module_path in imports:
176
- import_module(module_path)
165
+ def ui(host: str, port: int):
166
+ """Start the web UI dashboard.
177
167
 
168
+ The UI reads task definitions from active workers, so no --import is needed.
169
+ """
178
170
  click.echo(f"Starting Fairchild UI at http://{host}:{port}")
179
171
  asyncio.run(_run_ui(host, port))
180
172
 
@@ -0,0 +1,3 @@
1
+ -- Add tasks column to workers table
2
+ -- Workers publish their registered tasks (with schemas) on startup
3
+ ALTER TABLE fairchild_workers ADD COLUMN IF NOT EXISTS tasks JSONB DEFAULT '[]';
@@ -0,0 +1,2 @@
1
+ -- Rename recorded column to result
2
+ ALTER TABLE fairchild_jobs RENAME COLUMN recorded TO result;
fairchild/task.py CHANGED
@@ -15,6 +15,52 @@ def get_task(name: str) -> "Task":
15
15
  return _task_registry[name]
16
16
 
17
17
 
18
+ def get_task_schemas() -> list[dict]:
19
+ """Get schemas for all registered tasks.
20
+
21
+ Returns a list of task info dicts suitable for storing in the database.
22
+ """
23
+ tasks = []
24
+ for name, task in sorted(_task_registry.items()):
25
+ sig = inspect.signature(task.fn)
26
+ params = []
27
+ for param_name, param in sig.parameters.items():
28
+ if param_name in ("job", "workflow"):
29
+ continue
30
+
31
+ param_info = {"name": param_name}
32
+
33
+ if param.annotation != inspect.Parameter.empty:
34
+ try:
35
+ param_info["type"] = param.annotation.__name__
36
+ except AttributeError:
37
+ param_info["type"] = str(param.annotation)
38
+
39
+ if param.default != inspect.Parameter.empty:
40
+ param_info["default"] = param.default
41
+ param_info["required"] = False
42
+ else:
43
+ param_info["required"] = True
44
+
45
+ params.append(param_info)
46
+
47
+ docstring = inspect.getdoc(task.fn)
48
+
49
+ tasks.append(
50
+ {
51
+ "name": name,
52
+ "queue": task.queue,
53
+ "priority": task.priority,
54
+ "max_attempts": task.max_attempts,
55
+ "tags": task.tags,
56
+ "params": params,
57
+ "docstring": docstring,
58
+ }
59
+ )
60
+
61
+ return tasks
62
+
63
+
18
64
  def task(
19
65
  queue: str = "default",
20
66
  max_attempts: int = 3,
@@ -28,7 +74,7 @@ def task(
28
74
  Usage:
29
75
  @task(queue="default")
30
76
  def my_task(item_id: int):
31
- return Record({"result": item_id * 2})
77
+ return {"result": item_id * 2}
32
78
 
33
79
  # Enqueue
34
80
  my_task.enqueue(item_id=42)
@@ -151,13 +197,7 @@ class Task:
151
197
  return Future(job_id=job_id)
152
198
 
153
199
  # Not inside a task - execute directly
154
- from fairchild.record import Record
155
-
156
- result = self.fn(*args, **kwargs)
157
- # Unwrap Record so local runs behave like resolved futures
158
- if isinstance(result, Record):
159
- return result.value
160
- return result
200
+ return self.fn(*args, **kwargs)
161
201
 
162
202
  def _serialize_args(self, kwargs: dict[str, Any]) -> dict[str, Any]:
163
203
  """Serialize arguments, converting Futures to their job IDs."""
@@ -255,6 +255,16 @@
255
255
  .job-link:hover {
256
256
  text-decoration: underline;
257
257
  }
258
+ .task-args {
259
+ font-family: "SF Mono", Monaco, monospace;
260
+ font-size: 0.75rem;
261
+ color: var(--text-dim);
262
+ margin-top: 0.25rem;
263
+ max-width: 300px;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ white-space: nowrap;
267
+ }
258
268
 
259
269
  .queues {
260
270
  display: grid;
@@ -354,13 +364,6 @@
354
364
  font-weight: 500;
355
365
  }
356
366
 
357
- #workflows-table tbody tr {
358
- cursor: pointer;
359
- }
360
- #workflows-table tbody tr:hover {
361
- background: var(--bg-hover);
362
- }
363
-
364
367
  /* Enqueue Modal */
365
368
  .modal-overlay {
366
369
  display: none;
@@ -1214,23 +1217,10 @@
1214
1217
  }
1215
1218
  }
1216
1219
 
1217
- async function fetchWorkflows() {
1218
- const res = await fetch("/api/workflows");
1219
- const workflows = await res.json();
1220
-
1221
- const tbody = document.querySelector("#workflows-table tbody");
1222
- tbody.innerHTML = workflows
1223
- .map(
1224
- (wf) => `
1225
- <tr onclick="window.location='/workflows/${wf.workflow_id}'">
1226
- <td>${wf.workflow_name || wf.workflow_id.slice(0, 8)}</td>
1227
- <td>${wf.completed}/${wf.total_jobs} completed</td>
1228
- <td class="mono">${formatTime(wf.started_at)}</td>
1229
- <td class="mono">${wf.finished_at ? formatTime(wf.finished_at) : "-"}</td>
1230
- </tr>
1231
- `,
1232
- )
1233
- .join("");
1220
+ function formatArgs(args) {
1221
+ if (!args || Object.keys(args).length === 0) return "";
1222
+ const str = typeof args === "string" ? args : JSON.stringify(args);
1223
+ return str.length > 50 ? str.slice(0, 50) + "..." : str;
1234
1224
  }
1235
1225
 
1236
1226
  async function fetchJobs(state = "") {
@@ -1240,18 +1230,26 @@
1240
1230
 
1241
1231
  const tbody = document.querySelector("#jobs-table tbody");
1242
1232
  tbody.innerHTML = jobs
1243
- .map(
1244
- (job) => `
1233
+ .map((job) => {
1234
+ const argsDisplay = formatArgs(job.args);
1235
+ const argsTitle =
1236
+ typeof job.args === "string"
1237
+ ? job.args
1238
+ : JSON.stringify(job.args);
1239
+ const argsHtml = argsDisplay
1240
+ ? `<div class="task-args" title="${argsTitle.replace(/"/g, "&quot;")}">${argsDisplay}</div>`
1241
+ : "";
1242
+ return `
1245
1243
  <tr>
1246
1244
  <td class="mono"><a href="/jobs/${job.id}" class="job-link">${job.id}</a></td>
1247
- <td class="truncate">${job.task_name}</td>
1245
+ <td>${job.task_name}${argsHtml}</td>
1248
1246
  <td>${job.queue}</td>
1249
1247
  <td><span class="badge ${job.state}">${job.state}</span></td>
1250
1248
  <td>${job.attempt}/${job.max_attempts}</td>
1251
1249
  <td class="mono">${formatTime(job.inserted_at)}</td>
1252
1250
  </tr>
1253
- `,
1254
- )
1251
+ `;
1252
+ })
1255
1253
  .join("");
1256
1254
  }
1257
1255
 
@@ -1462,7 +1460,6 @@
1462
1460
  fetchStats(),
1463
1461
  fetchQueues(),
1464
1462
  fetchWorkers(currentWorkerState),
1465
- fetchWorkflows(),
1466
1463
  fetchJobs(currentState),
1467
1464
  fetchTimeseries(),
1468
1465
  ]);
@@ -1484,14 +1481,17 @@
1484
1481
  }, 1000);
1485
1482
 
1486
1483
  // Enqueue modal
1487
- let tasksLoaded = false;
1488
1484
  let taskRegistry = {};
1489
1485
 
1490
1486
  async function loadTasks() {
1491
- if (tasksLoaded) return;
1492
1487
  const res = await fetch("/api/tasks");
1493
1488
  const tasks = await res.json();
1494
1489
  const select = document.getElementById("enqueue-task");
1490
+
1491
+ // Clear existing options except the placeholder
1492
+ select.innerHTML = '<option value="">Select a task...</option>';
1493
+ taskRegistry = {};
1494
+
1495
1495
  tasks.forEach((task) => {
1496
1496
  taskRegistry[task.name] = task;
1497
1497
  const option = document.createElement("option");
@@ -1499,7 +1499,6 @@
1499
1499
  option.textContent = `${task.name} (queue: ${task.queue})`;
1500
1500
  select.appendChild(option);
1501
1501
  });
1502
- tasksLoaded = true;
1503
1502
  }
1504
1503
 
1505
1504
  function onTaskSelected() {
@@ -392,6 +392,64 @@
392
392
  border-radius: 4px;
393
393
  }
394
394
 
395
+ /* Task Summary Stats */
396
+ .task-summary {
397
+ margin-bottom: 1rem;
398
+ overflow-x: auto;
399
+ }
400
+ .task-summary table {
401
+ width: 100%;
402
+ border-collapse: collapse;
403
+ font-size: 0.8125rem;
404
+ }
405
+ .task-summary th,
406
+ .task-summary td {
407
+ padding: 0.5rem 0.75rem;
408
+ text-align: left;
409
+ border-bottom: 1px solid var(--border-color);
410
+ }
411
+ .task-summary th {
412
+ background: var(--bg-secondary);
413
+ color: var(--text-muted);
414
+ font-weight: 500;
415
+ text-transform: uppercase;
416
+ font-size: 0.6875rem;
417
+ }
418
+ .task-summary td {
419
+ color: var(--text-secondary);
420
+ }
421
+ .task-summary .task-name {
422
+ font-family: "SF Mono", Monaco, monospace;
423
+ font-weight: 500;
424
+ color: var(--text-primary);
425
+ }
426
+ .task-summary .stat-cell {
427
+ text-align: center;
428
+ min-width: 80px;
429
+ }
430
+ .task-summary .stat-cell .count {
431
+ font-weight: 600;
432
+ }
433
+ .task-summary .stat-cell .pct {
434
+ color: var(--text-dim);
435
+ font-size: 0.75rem;
436
+ margin-left: 0.25rem;
437
+ }
438
+ .task-summary .total-row {
439
+ font-weight: 600;
440
+ background: var(--bg-secondary);
441
+ }
442
+ .task-summary .total-row td {
443
+ border-bottom: none;
444
+ }
445
+ .task-summary .timing-cell {
446
+ white-space: nowrap;
447
+ }
448
+ .task-summary .timing-range {
449
+ color: var(--text-dim);
450
+ font-size: 0.7rem;
451
+ }
452
+
395
453
  #family-table {
396
454
  width: 100%;
397
455
  border-collapse: collapse;
@@ -522,6 +580,9 @@
522
580
  </div>
523
581
  <div id="dag-flow"></div>
524
582
  </div>
583
+ <div class="task-summary" id="task-summary" style="margin-top: 16px">
584
+ <!-- Populated by JS -->
585
+ </div>
525
586
  <table id="family-table" style="margin-top: 16px">
526
587
  <thead>
527
588
  <tr>
@@ -755,10 +816,10 @@
755
816
  );
756
817
 
757
818
  // Result
758
- if (job.recorded !== null && job.recorded !== undefined) {
819
+ if (job.result !== null && job.result !== undefined) {
759
820
  document.getElementById("result-card").style.display = "block";
760
821
  document.getElementById("result-block").textContent = formatJson(
761
- job.recorded,
822
+ job.result,
762
823
  );
763
824
  }
764
825
 
@@ -815,10 +876,21 @@
815
876
 
816
877
  familyData = await res.json();
817
878
 
818
- // Only show DAG if there's more than one job in the family
819
- if (familyData.jobs && familyData.jobs.length > 1) {
879
+ // Find the current job in the family
880
+ const currentJob = familyData.jobs.find((j) => j.id === jobId);
881
+
882
+ // Only show family view if:
883
+ // 1. Current job is the root (no parent_id) AND has children
884
+ // 2. Don't show for child jobs - they should just see their own info
885
+ const isRoot = currentJob && !currentJob.parent_id;
886
+ const hasChildren = familyData.jobs.some(
887
+ (j) => j.parent_id === jobId,
888
+ );
889
+
890
+ if (isRoot && familyData.jobs.length > 1) {
820
891
  document.getElementById("family-card").style.display = "block";
821
892
  renderDAG(familyData.jobs);
893
+ renderTaskSummary(familyData.jobs);
822
894
  renderFamilyTable(familyData.jobs);
823
895
  }
824
896
  } catch (err) {
@@ -1190,6 +1262,149 @@
1190
1262
  );
1191
1263
  }
1192
1264
 
1265
+ function renderTaskSummary(jobs) {
1266
+ const container = document.getElementById("task-summary");
1267
+ const allStates = [
1268
+ "completed",
1269
+ "running",
1270
+ "available",
1271
+ "scheduled",
1272
+ "failed",
1273
+ "discarded",
1274
+ ];
1275
+ const stateLabels = {
1276
+ completed: "Done",
1277
+ running: "Run",
1278
+ available: "Avail",
1279
+ scheduled: "Sched",
1280
+ failed: "Fail",
1281
+ discarded: "Disc",
1282
+ };
1283
+
1284
+ function fmtDur(ms) {
1285
+ if (ms < 1000) return Math.round(ms) + "ms";
1286
+ if (ms < 60000) return (ms / 1000).toFixed(1) + "s";
1287
+ if (ms < 3600000) return (ms / 60000).toFixed(1) + "m";
1288
+ return (ms / 3600000).toFixed(1) + "h";
1289
+ }
1290
+
1291
+ const taskStats = {};
1292
+ let totals = { total: 0, durations: [] };
1293
+ allStates.forEach((s) => (totals[s] = 0));
1294
+
1295
+ jobs.forEach((job) => {
1296
+ const taskName = job.task_name;
1297
+ if (!taskStats[taskName]) {
1298
+ taskStats[taskName] = { total: 0, durations: [] };
1299
+ allStates.forEach((s) => (taskStats[taskName][s] = 0));
1300
+ }
1301
+ taskStats[taskName].total++;
1302
+ taskStats[taskName][job.state] =
1303
+ (taskStats[taskName][job.state] || 0) + 1;
1304
+ totals.total++;
1305
+ totals[job.state] = (totals[job.state] || 0) + 1;
1306
+
1307
+ // Include timing for completed, failed, and discarded jobs
1308
+ if (
1309
+ ["completed", "failed", "discarded"].includes(job.state) &&
1310
+ job.attempted_at &&
1311
+ job.completed_at
1312
+ ) {
1313
+ const duration =
1314
+ new Date(job.completed_at) - new Date(job.attempted_at);
1315
+ taskStats[taskName].durations.push(duration);
1316
+ totals.durations.push(duration);
1317
+ }
1318
+ });
1319
+
1320
+ // Show all state columns for consistency
1321
+ const states = allStates;
1322
+
1323
+ function calcDurationStats(durations) {
1324
+ if (durations.length === 0) return null;
1325
+ const sorted = durations.slice().sort((a, b) => a - b);
1326
+ const sum = sorted.reduce((a, b) => a + b, 0);
1327
+ const p95Index = Math.floor(sorted.length * 0.95);
1328
+ return {
1329
+ avg: sum / sorted.length,
1330
+ p95: sorted[Math.min(p95Index, sorted.length - 1)],
1331
+ };
1332
+ }
1333
+
1334
+ const taskNames = Object.keys(taskStats).sort();
1335
+ const headerCells = states
1336
+ .map((s) => `<th class="stat-cell">${stateLabels[s]}</th>`)
1337
+ .join("");
1338
+
1339
+ const rows = taskNames
1340
+ .map((taskName) => {
1341
+ const stats = taskStats[taskName];
1342
+ const shortName = taskName.split(".").pop();
1343
+ const ds = calcDurationStats(stats.durations);
1344
+
1345
+ const statCells = states
1346
+ .map((s) => {
1347
+ const count = stats[s] || 0;
1348
+ if (count === 0) return `<td class="stat-cell">-</td>`;
1349
+ const pct =
1350
+ stats.total > 0 ? Math.round((count / stats.total) * 100) : 0;
1351
+ return `<td class="stat-cell"><span class="count">${count}</span><span class="pct">(${pct}%)</span></td>`;
1352
+ })
1353
+ .join("");
1354
+
1355
+ const timing = ds
1356
+ ? `${fmtDur(ds.avg)}<span class="timing-range"> (${fmtDur(ds.p95)})</span>`
1357
+ : "-";
1358
+
1359
+ return `<tr>
1360
+ <td class="task-name" title="${taskName}">${shortName}</td>
1361
+ <td class="stat-cell">${stats.total}</td>
1362
+ ${statCells}
1363
+ <td class="stat-cell timing-cell">${timing}</td>
1364
+ </tr>`;
1365
+ })
1366
+ .join("");
1367
+
1368
+ const tds = calcDurationStats(totals.durations);
1369
+ const totalCells = states
1370
+ .map((s) => {
1371
+ const count = totals[s] || 0;
1372
+ if (count === 0) return `<td class="stat-cell">-</td>`;
1373
+ const pct =
1374
+ totals.total > 0 ? Math.round((count / totals.total) * 100) : 0;
1375
+ return `<td class="stat-cell"><span class="count">${count}</span><span class="pct">(${pct}%)</span></td>`;
1376
+ })
1377
+ .join("");
1378
+
1379
+ const totalTiming = tds
1380
+ ? `${fmtDur(tds.avg)}<span class="timing-range"> (${fmtDur(tds.p95)})</span>`
1381
+ : "-";
1382
+
1383
+ const totalRow = `<tr class="total-row">
1384
+ <td>Total</td>
1385
+ <td class="stat-cell">${totals.total}</td>
1386
+ ${totalCells}
1387
+ <td class="stat-cell timing-cell">${totalTiming}</td>
1388
+ </tr>`;
1389
+
1390
+ container.innerHTML = `
1391
+ <table>
1392
+ <thead>
1393
+ <tr>
1394
+ <th>Task</th>
1395
+ <th class="stat-cell">#</th>
1396
+ ${headerCells}
1397
+ <th class="stat-cell">Avg (p95)</th>
1398
+ </tr>
1399
+ </thead>
1400
+ <tbody>
1401
+ ${rows}
1402
+ ${totalRow}
1403
+ </tbody>
1404
+ </table>
1405
+ `;
1406
+ }
1407
+
1193
1408
  function renderFamilyTable(jobs) {
1194
1409
  const tbody = document.querySelector("#family-table tbody");
1195
1410
 
@@ -1214,13 +1429,15 @@
1214
1429
  }
1215
1430
 
1216
1431
  let result = "-";
1217
- if (job.recorded !== null && job.recorded !== undefined) {
1218
- const recorded =
1219
- typeof job.recorded === "string"
1220
- ? job.recorded
1221
- : JSON.stringify(job.recorded);
1432
+ if (job.result !== null && job.result !== undefined) {
1433
+ const resultStr =
1434
+ typeof job.result === "string"
1435
+ ? job.result
1436
+ : JSON.stringify(job.result);
1222
1437
  result =
1223
- recorded.length > 30 ? recorded.slice(0, 30) + "..." : recorded;
1438
+ resultStr.length > 30
1439
+ ? resultStr.slice(0, 30) + "..."
1440
+ : resultStr;
1224
1441
  }
1225
1442
 
1226
1443
  return `
fairchild/ui.py CHANGED
@@ -96,52 +96,32 @@ async def api_queues(request: web.Request) -> web.Response:
96
96
 
97
97
 
98
98
  async def api_tasks(request: web.Request) -> web.Response:
99
- """Get list of registered tasks with parameter info."""
100
- import inspect
101
- from fairchild.task import _task_registry
99
+ """Get list of registered tasks from active workers."""
100
+ fairchild: Fairchild = request.app[_fairchild_key]
101
+
102
+ # Get tasks from workers that have heartbeated recently (alive)
103
+ query = """
104
+ SELECT tasks FROM fairchild_workers
105
+ WHERE last_heartbeat_at > now() - interval '30 seconds'
106
+ AND state != 'stopped'
107
+ """
108
+ rows = await fairchild._pool.fetch(query)
102
109
 
110
+ # Aggregate unique tasks from all workers
111
+ seen = set()
103
112
  tasks = []
104
- for name, task in sorted(_task_registry.items()):
105
- # Extract parameter info from function signature
106
- sig = inspect.signature(task.fn)
107
- params = []
108
- for param_name, param in sig.parameters.items():
109
- # Skip special injected parameters
110
- if param_name in ("job", "workflow"):
111
- continue
112
-
113
- param_info = {"name": param_name}
114
-
115
- # Add type annotation if present
116
- if param.annotation != inspect.Parameter.empty:
117
- try:
118
- param_info["type"] = param.annotation.__name__
119
- except AttributeError:
120
- param_info["type"] = str(param.annotation)
121
-
122
- # Add default value if present
123
- if param.default != inspect.Parameter.empty:
124
- param_info["default"] = param.default
125
- param_info["required"] = False
126
- else:
127
- param_info["required"] = True
128
-
129
- params.append(param_info)
130
-
131
- # Get docstring
132
- docstring = inspect.getdoc(task.fn)
133
-
134
- tasks.append(
135
- {
136
- "name": name,
137
- "queue": task.queue,
138
- "priority": task.priority,
139
- "max_attempts": task.max_attempts,
140
- "tags": task.tags,
141
- "params": params,
142
- "docstring": docstring,
143
- }
144
- )
113
+ for row in rows:
114
+ worker_tasks = row["tasks"] or []
115
+ # Handle case where tasks is a JSON string (asyncpg returns jsonb as parsed, but just in case)
116
+ if isinstance(worker_tasks, str):
117
+ worker_tasks = json.loads(worker_tasks)
118
+ for task in worker_tasks:
119
+ if task["name"] not in seen:
120
+ seen.add(task["name"])
121
+ tasks.append(task)
122
+
123
+ # Sort by name
124
+ tasks.sort(key=lambda t: t["name"])
145
125
 
146
126
  return web.json_response(tasks)
147
127
 
@@ -179,7 +159,7 @@ async def api_jobs(request: web.Request) -> web.Response:
179
159
  parent_id, deps,
180
160
  state, priority, scheduled_at,
181
161
  attempted_at, completed_at, attempt, max_attempts,
182
- recorded, errors, tags,
162
+ result, errors, tags,
183
163
  inserted_at, updated_at
184
164
  FROM fairchild_jobs
185
165
  {where}
@@ -234,8 +214,8 @@ async def api_enqueue_job(request: web.Request) -> web.Response:
234
214
  "scheduled_at": "ISO8601"
235
215
  }
236
216
  """
237
- from datetime import datetime
238
- from fairchild.task import get_task
217
+ from datetime import datetime, timezone
218
+ from fairchild.job import Job, JobState
239
219
 
240
220
  fairchild: Fairchild = request.app[_fairchild_key]
241
221
 
@@ -249,10 +229,27 @@ async def api_enqueue_job(request: web.Request) -> web.Response:
249
229
  if not task_name:
250
230
  return web.json_response({"error": "Missing required field: task"}, status=400)
251
231
 
252
- # Look up the task
253
- try:
254
- task = get_task(task_name)
255
- except ValueError:
232
+ # Look up the task from active workers
233
+ query = """
234
+ SELECT tasks FROM fairchild_workers
235
+ WHERE last_heartbeat_at > now() - interval '30 seconds'
236
+ AND state != 'stopped'
237
+ """
238
+ rows = await fairchild._pool.fetch(query)
239
+
240
+ task_info = None
241
+ for row in rows:
242
+ worker_tasks = row["tasks"] or []
243
+ if isinstance(worker_tasks, str):
244
+ worker_tasks = json.loads(worker_tasks)
245
+ for t in worker_tasks:
246
+ if t["name"] == task_name:
247
+ task_info = t
248
+ break
249
+ if task_info:
250
+ break
251
+
252
+ if not task_info:
256
253
  return web.json_response({"error": f"Unknown task: {task_name}"}, status=404)
257
254
 
258
255
  # Parse optional fields
@@ -278,14 +275,21 @@ async def api_enqueue_job(request: web.Request) -> web.Response:
278
275
  {"error": "scheduled_at must be a valid ISO8601 datetime"}, status=400
279
276
  )
280
277
 
281
- # Enqueue the job
278
+ # Create and insert the job directly using task info from workers
279
+ now = datetime.now(timezone.utc)
280
+ job = Job(
281
+ task_name=task_name,
282
+ queue=task_info["queue"],
283
+ args=args,
284
+ priority=priority if priority is not None else task_info["priority"],
285
+ max_attempts=task_info["max_attempts"],
286
+ tags=task_info.get("tags", []),
287
+ scheduled_at=scheduled_at or now,
288
+ state=JobState.AVAILABLE if scheduled_at is None else JobState.SCHEDULED,
289
+ )
290
+
282
291
  try:
283
- job = await fairchild.enqueue(
284
- task=task,
285
- args=args,
286
- priority=priority,
287
- scheduled_at=scheduled_at,
288
- )
292
+ await fairchild._insert_job(job)
289
293
  except Exception as e:
290
294
  return web.json_response({"error": f"Failed to enqueue job: {e}"}, status=500)
291
295
 
@@ -312,7 +316,7 @@ async def api_job_detail(request: web.Request) -> web.Response:
312
316
  parent_id, deps,
313
317
  state, priority, scheduled_at,
314
318
  attempted_at, completed_at, attempt, max_attempts,
315
- recorded, errors, tags,
319
+ result, errors, tags,
316
320
  inserted_at, updated_at
317
321
  FROM fairchild_jobs
318
322
  WHERE id = $1
@@ -378,15 +382,15 @@ async def api_job_family(request: web.Request) -> web.Response:
378
382
  # Get all descendants from the root
379
383
  family_query = """
380
384
  WITH RECURSIVE family AS (
381
- SELECT id, task_name, parent_id, state, deps, recorded,
382
- attempted_at, completed_at, attempt, max_attempts
385
+ SELECT id, task_name, parent_id, state, deps, result,
386
+ attempted_at, completed_at, updated_at, attempt, max_attempts
383
387
  FROM fairchild_jobs
384
388
  WHERE id = $1
385
389
 
386
390
  UNION ALL
387
391
 
388
- SELECT j.id, j.task_name, j.parent_id, j.state, j.deps, j.recorded,
389
- j.attempted_at, j.completed_at, j.attempt, j.max_attempts
392
+ SELECT j.id, j.task_name, j.parent_id, j.state, j.deps, j.result,
393
+ j.attempted_at, j.completed_at, j.updated_at, j.attempt, j.max_attempts
390
394
  FROM fairchild_jobs j
391
395
  INNER JOIN family f ON j.parent_id = f.id
392
396
  )
@@ -405,6 +409,7 @@ async def api_job_family(request: web.Request) -> web.Response:
405
409
  job["completed_at"] = (
406
410
  job["completed_at"].isoformat() if job["completed_at"] else None
407
411
  )
412
+ job["updated_at"] = job["updated_at"].isoformat() if job["updated_at"] else None
408
413
  jobs.append(job)
409
414
 
410
415
  return web.json_response({"root_id": str(root_id), "jobs": jobs})
fairchild/worker.py CHANGED
@@ -10,8 +10,7 @@ from uuid import uuid4
10
10
  from fairchild.context import set_current_job, get_pending_children
11
11
  from fairchild.fairchild import Fairchild
12
12
  from fairchild.job import Job, JobState
13
- from fairchild.record import Record
14
- from fairchild.task import get_task
13
+ from fairchild.task import get_task, get_task_schemas
15
14
 
16
15
 
17
16
  class Worker:
@@ -120,9 +119,9 @@ class Worker:
120
119
  # Check if this is a future marker
121
120
  if "__future__" in obj and len(obj) == 1:
122
121
  job_id = obj["__future__"]
123
- # Fetch the recorded result from the completed job
122
+ # Fetch the result from the completed job
124
123
  query = """
125
- SELECT recorded FROM fairchild_jobs
124
+ SELECT result FROM fairchild_jobs
126
125
  WHERE id = $1 AND state = 'completed'
127
126
  """
128
127
  from uuid import UUID
@@ -180,19 +179,23 @@ class Worker:
180
179
  for child_job in pending_children:
181
180
  await self.fairchild._insert_job(child_job)
182
181
 
183
- # Handle Record() return values
184
- recorded_value = None
185
- if isinstance(result, Record):
186
- recorded_value = result.value
182
+ # Try to capture the return value as JSON
183
+ result_value = None
184
+ if result is not None:
185
+ try:
186
+ json.dumps(result) # Test if serializable
187
+ result_value = result
188
+ except (TypeError, ValueError):
189
+ pass # Not JSON-serializable, skip
187
190
 
188
191
  # Check if this job spawned children - if so, wait for them
189
192
  has_children = len(pending_children) > 0
190
193
  if has_children:
191
194
  # Keep job in running state, it will be completed when children finish
192
- await self._mark_waiting_for_children(job, recorded_value)
195
+ await self._mark_waiting_for_children(job, result_value)
193
196
  print(f"[{self.name}] Job {job.id} waiting for children to complete")
194
197
  else:
195
- await self._complete_job(job, recorded_value)
198
+ await self._complete_job(job, result_value)
196
199
  print(f"[{self.name}] Completed job {job.id}")
197
200
 
198
201
  except Exception as e:
@@ -214,19 +217,19 @@ class Worker:
214
217
  f"[{self.name}] Job {job.id} failed (attempt {job.attempt}/{job.max_attempts}): {e}"
215
218
  )
216
219
 
217
- async def _complete_job(self, job: Job, recorded: Any | None):
220
+ async def _complete_job(self, job: Job, result: Any | None):
218
221
  """Mark a job as completed."""
219
222
  query = """
220
223
  UPDATE fairchild_jobs
221
224
  SET state = 'completed',
222
225
  completed_at = now(),
223
- recorded = $2,
226
+ result = $2,
224
227
  updated_at = now()
225
228
  WHERE id = $1
226
229
  """
227
230
 
228
- recorded_json = json.dumps(recorded) if recorded is not None else None
229
- await self.fairchild._pool.execute(query, job.id, recorded_json)
231
+ result_json = json.dumps(result) if result is not None else None
232
+ await self.fairchild._pool.execute(query, job.id, result_json)
230
233
 
231
234
  # Check if this completion unblocks jobs waiting on this one
232
235
  await self._check_child_deps(job)
@@ -246,17 +249,17 @@ class Worker:
246
249
  """
247
250
  return await self.fairchild._pool.fetchval(query, job.id)
248
251
 
249
- async def _mark_waiting_for_children(self, job: Job, recorded: Any | None):
252
+ async def _mark_waiting_for_children(self, job: Job, result: Any | None):
250
253
  """Mark a job as waiting for children (stays in running state but stores result)."""
251
- # Store the recorded value so we can use it when completing later
254
+ # Store the result value so we can use it when completing later
252
255
  query = """
253
256
  UPDATE fairchild_jobs
254
- SET recorded = $2,
257
+ SET result = $2,
255
258
  updated_at = now()
256
259
  WHERE id = $1
257
260
  """
258
- recorded_json = json.dumps(recorded) if recorded is not None else None
259
- await self.fairchild._pool.execute(query, job.id, recorded_json)
261
+ result_json = json.dumps(result) if result is not None else None
262
+ await self.fairchild._pool.execute(query, job.id, result_json)
260
263
 
261
264
  async def _check_parent_completion(self, parent_id):
262
265
  """Check if a parent job can be completed (all children done)."""
@@ -326,6 +329,7 @@ class Worker:
326
329
  UPDATE fairchild_jobs
327
330
  SET state = $2,
328
331
  errors = errors || $3::jsonb,
332
+ completed_at = now(),
329
333
  updated_at = now()
330
334
  WHERE id = $1
331
335
  """
@@ -338,7 +342,7 @@ class Worker:
338
342
  else:
339
343
  query = """
340
344
  UPDATE fairchild_jobs
341
- SET state = $2, updated_at = now()
345
+ SET state = $2, completed_at = now(), updated_at = now()
342
346
  WHERE id = $1
343
347
  """
344
348
  await self.fairchild._pool.execute(query, job.id, new_state)
@@ -398,26 +402,30 @@ class WorkerPool:
398
402
  async def _register(self):
399
403
  """Register this worker pool in the database."""
400
404
  query = """
401
- INSERT INTO fairchild_workers (id, hostname, pid, queues, state)
402
- VALUES ($1, $2, $3, $4::jsonb, 'running')
405
+ INSERT INTO fairchild_workers (id, hostname, pid, queues, tasks, state)
406
+ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, 'running')
403
407
  ON CONFLICT (id) DO UPDATE SET
404
408
  hostname = EXCLUDED.hostname,
405
409
  pid = EXCLUDED.pid,
406
410
  queues = EXCLUDED.queues,
411
+ tasks = EXCLUDED.tasks,
407
412
  state = 'running',
408
413
  last_heartbeat_at = now()
409
414
  """
410
415
  # asyncpg requires JSON as a string for jsonb columns
411
416
  import json as json_module
412
417
 
418
+ tasks = get_task_schemas()
413
419
  await self.fairchild._pool.execute(
414
420
  query,
415
421
  self.id,
416
422
  self.hostname,
417
423
  self.pid,
418
424
  json_module.dumps(self.queue_config),
425
+ json_module.dumps(tasks),
419
426
  )
420
427
  print(f"Registered worker pool {self.id} ({self.hostname}:{self.pid})")
428
+ print(f" Tasks: {[t['name'] for t in tasks]}")
421
429
 
422
430
  async def _heartbeat_loop(self):
423
431
  """Periodically send heartbeats and check for pause state."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fairchild
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Workflow scheduling with PostgreSQL
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1,25 @@
1
+ fairchild/__init__.py,sha256=80mYyoeUFY-85M5lipyZE-4QPTdJ2fqM6KsFUqAyytw,159
2
+ fairchild/cli.py,sha256=LOrF4e6LgMasWGmeRpciD6iDptC3IiiVF53Ll-TzN8M,9895
3
+ fairchild/context.py,sha256=64yEo5Cj4LeBT6LQd2UdN00tM7SXSd2Vw_dM4RV1u68,1757
4
+ fairchild/fairchild.py,sha256=QxGPvkhedyaEtjtdavIONgfX3O4H5BKo1hG1T2WFNJ4,4451
5
+ fairchild/future.py,sha256=SeVm_6Ds4k3qdwpKnFl71Qknx9tRM9v3UWZ_tJCp7cw,2302
6
+ fairchild/job.py,sha256=_ilXLHg1aQOM4xOUZArT-UDGzPQnKVDYKvru4Qz1MXs,3767
7
+ fairchild/task.py,sha256=TsOjs2UYt8qN8WV3YxOP_4ppzBARh4ySz4eM1O9z4II,8135
8
+ fairchild/ui.py,sha256=apOUmn7HZYg06F4J-Sf4EteV97U6YVmcFvE8fiHX7GI,18213
9
+ fairchild/worker.py,sha256=OIv_NnKHU3fZuIQvDZqvcJ7-2EOvmfIctksxAuEzu_8,17421
10
+ fairchild/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ fairchild/db/migrations.py,sha256=tL5UPqllCipYC-XcEH_4ZfaRZLtJKaPACp70ZusvEvg,2174
12
+ fairchild/db/migrations/001_initial.sql,sha256=FFObm3bIHdASd3d7lXzuAzWsmkCm3OAO2F63pw6HcMs,1711
13
+ fairchild/db/migrations/002_add_parent_id.sql,sha256=ftDT053BaMnXX42aDxTtHnRnAgNKzIaUd1Fz_sO6LyQ,295
14
+ fairchild/db/migrations/003_remove_workflows.sql,sha256=6svXO2nM7JiO-QHXfzIJCd6DeOSZ0AFQDsuQgPPv4IY,368
15
+ fairchild/db/migrations/004_add_workers.sql,sha256=SOvHykAinr_O9gDf7kllEUZ57pw-USoHUsYLCfQumMI,714
16
+ fairchild/db/migrations/005_add_worker_tasks.sql,sha256=0VJA5HBE2Aidjmk72nqLzreWA8A2jRDZsg1-6rcqTzo,186
17
+ fairchild/db/migrations/006_rename_recorded_to_result.sql,sha256=LAKa_VnK5hXIeUY9rJNWlOYGz5hBV7ttHuKHlevWCjY,97
18
+ fairchild/templates/dashboard.html,sha256=2kF3RLUMuZqQWbdlV-GPvYsBD8JnA_nWf4lBqRt60Sc,50071
19
+ fairchild/templates/job.html,sha256=ptliW9AGguv0EaouSH5HFWfz-JqpuTj17ih8LbR2eQw,45059
20
+ fairchild-0.0.6.dist-info/licenses/LICENSE,sha256=ad6qehkQLI1ax2pV6ocs7YePX06CPLBs3SThRbNC0q0,1068
21
+ fairchild-0.0.6.dist-info/METADATA,sha256=mBRHxvdGvnjGhRoFvAT_FnnfrAwbqqINfV5M4RwvIfk,11006
22
+ fairchild-0.0.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
23
+ fairchild-0.0.6.dist-info/entry_points.txt,sha256=urOgjfuYex5__jBX91srCX8T5GgHvYZDPpWYkuA7z90,49
24
+ fairchild-0.0.6.dist-info/top_level.txt,sha256=_2zkPnqS4i3JjLMpxRPDrBS0a04KBXZiD1NHFv7CO4U,10
25
+ fairchild-0.0.6.dist-info/RECORD,,
fairchild/record.py DELETED
@@ -1,22 +0,0 @@
1
- from dataclasses import dataclass
2
- from typing import Any
3
-
4
-
5
- @dataclass(frozen=True)
6
- class Record:
7
- """Wrapper to indicate a task's return value should be persisted.
8
-
9
- Usage:
10
- @task(queue="default")
11
- def my_task(item_id: int):
12
- result = process(item_id)
13
- return Record({"item_id": item_id, "result": result})
14
-
15
- The recorded value will be stored in the job's `recorded` column
16
- and can be retrieved by downstream jobs in a workflow.
17
- """
18
-
19
- value: Any
20
-
21
- def __repr__(self) -> str:
22
- return f"Record({self.value!r})"
@@ -1,24 +0,0 @@
1
- fairchild/__init__.py,sha256=Q7FvQFBPDM12tf2hAg_nlROSdg7dYIXWnDwyqX-6mLU,209
2
- fairchild/cli.py,sha256=H0Y1Zr6POsbnRCJIoMVh65jLYiPt9_-kPNP4_5MGrjQ,10077
3
- fairchild/context.py,sha256=64yEo5Cj4LeBT6LQd2UdN00tM7SXSd2Vw_dM4RV1u68,1757
4
- fairchild/fairchild.py,sha256=QxGPvkhedyaEtjtdavIONgfX3O4H5BKo1hG1T2WFNJ4,4451
5
- fairchild/future.py,sha256=SeVm_6Ds4k3qdwpKnFl71Qknx9tRM9v3UWZ_tJCp7cw,2302
6
- fairchild/job.py,sha256=_ilXLHg1aQOM4xOUZArT-UDGzPQnKVDYKvru4Qz1MXs,3767
7
- fairchild/record.py,sha256=DjTQgeE2YjjsU2nB3pegB0Njq1_sNWgJialPLZQ1k7c,575
8
- fairchild/task.py,sha256=Nwt8FxDV7PK4y4nMXingFPPmPX4HLhamlJrSacyXgIg,6964
9
- fairchild/ui.py,sha256=q7Bj60PAulMtt5KM3UvkFqpGJuKvc4P8Vv3RN5tIirs,17806
10
- fairchild/worker.py,sha256=cZzHLuE7CF9bTLy_59zUeZHQs_p7z2VUMJfcMZTjWLs,17045
11
- fairchild/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- fairchild/db/migrations.py,sha256=tL5UPqllCipYC-XcEH_4ZfaRZLtJKaPACp70ZusvEvg,2174
13
- fairchild/db/migrations/001_initial.sql,sha256=FFObm3bIHdASd3d7lXzuAzWsmkCm3OAO2F63pw6HcMs,1711
14
- fairchild/db/migrations/002_add_parent_id.sql,sha256=ftDT053BaMnXX42aDxTtHnRnAgNKzIaUd1Fz_sO6LyQ,295
15
- fairchild/db/migrations/003_remove_workflows.sql,sha256=6svXO2nM7JiO-QHXfzIJCd6DeOSZ0AFQDsuQgPPv4IY,368
16
- fairchild/db/migrations/004_add_workers.sql,sha256=SOvHykAinr_O9gDf7kllEUZ57pw-USoHUsYLCfQumMI,714
17
- fairchild/templates/dashboard.html,sha256=Ng0q9iXGdDSo74ltABUupzjbKhqpofC8S3Zw7wXUmLA,50041
18
- fairchild/templates/job.html,sha256=Pg7hd5p8dU5XJeCW9SryvDbvcErY1AMrxYm0z8hldnA,38042
19
- fairchild-0.0.4.dist-info/licenses/LICENSE,sha256=ad6qehkQLI1ax2pV6ocs7YePX06CPLBs3SThRbNC0q0,1068
20
- fairchild-0.0.4.dist-info/METADATA,sha256=RKftba_0moODAJNOAQiwVeYtC6HmXJuKGerN4VyHZDE,11006
21
- fairchild-0.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
- fairchild-0.0.4.dist-info/entry_points.txt,sha256=urOgjfuYex5__jBX91srCX8T5GgHvYZDPpWYkuA7z90,49
23
- fairchild-0.0.4.dist-info/top_level.txt,sha256=_2zkPnqS4i3JjLMpxRPDrBS0a04KBXZiD1NHFv7CO4U,10
24
- fairchild-0.0.4.dist-info/RECORD,,