fairchild 0.0.5__tar.gz → 0.0.6__tar.gz
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-0.0.5 → fairchild-0.0.6}/PKG-INFO +1 -1
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/__init__.py +0 -2
- fairchild-0.0.6/fairchild/db/migrations/006_rename_recorded_to_result.sql +2 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/task.py +2 -8
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/templates/dashboard.html +29 -5
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/templates/job.html +227 -10
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/ui.py +7 -6
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/worker.py +23 -19
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild.egg-info/PKG-INFO +1 -1
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild.egg-info/SOURCES.txt +1 -2
- {fairchild-0.0.5 → fairchild-0.0.6}/pyproject.toml +1 -1
- {fairchild-0.0.5 → fairchild-0.0.6}/tests/test_cli.py +49 -35
- {fairchild-0.0.5 → fairchild-0.0.6}/tests/test_task.py +17 -22
- fairchild-0.0.5/fairchild/record.py +0 -22
- fairchild-0.0.5/tests/test_record.py +0 -40
- {fairchild-0.0.5 → fairchild-0.0.6}/LICENSE +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/README.md +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/cli.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/context.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/__init__.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/migrations/001_initial.sql +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/migrations/002_add_parent_id.sql +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/migrations/003_remove_workflows.sql +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/migrations/004_add_workers.sql +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/migrations/005_add_worker_tasks.sql +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/db/migrations.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/fairchild.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/future.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild/job.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild.egg-info/dependency_links.txt +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild.egg-info/entry_points.txt +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild.egg-info/requires.txt +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/fairchild.egg-info/top_level.txt +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/setup.cfg +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/tests/test_integration.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/tests/test_job.py +0 -0
- {fairchild-0.0.5 → fairchild-0.0.6}/tests/test_web_ui.py +0 -0
|
@@ -74,7 +74,7 @@ def task(
|
|
|
74
74
|
Usage:
|
|
75
75
|
@task(queue="default")
|
|
76
76
|
def my_task(item_id: int):
|
|
77
|
-
return
|
|
77
|
+
return {"result": item_id * 2}
|
|
78
78
|
|
|
79
79
|
# Enqueue
|
|
80
80
|
my_task.enqueue(item_id=42)
|
|
@@ -197,13 +197,7 @@ class Task:
|
|
|
197
197
|
return Future(job_id=job_id)
|
|
198
198
|
|
|
199
199
|
# Not inside a task - execute directly
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
result = self.fn(*args, **kwargs)
|
|
203
|
-
# Unwrap Record so local runs behave like resolved futures
|
|
204
|
-
if isinstance(result, Record):
|
|
205
|
-
return result.value
|
|
206
|
-
return result
|
|
200
|
+
return self.fn(*args, **kwargs)
|
|
207
201
|
|
|
208
202
|
def _serialize_args(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
209
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;
|
|
@@ -1207,6 +1217,12 @@
|
|
|
1207
1217
|
}
|
|
1208
1218
|
}
|
|
1209
1219
|
|
|
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;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1210
1226
|
async function fetchJobs(state = "") {
|
|
1211
1227
|
const url = state ? `/api/jobs?state=${state}` : "/api/jobs";
|
|
1212
1228
|
const res = await fetch(url);
|
|
@@ -1214,18 +1230,26 @@
|
|
|
1214
1230
|
|
|
1215
1231
|
const tbody = document.querySelector("#jobs-table tbody");
|
|
1216
1232
|
tbody.innerHTML = jobs
|
|
1217
|
-
.map(
|
|
1218
|
-
(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, """)}">${argsDisplay}</div>`
|
|
1241
|
+
: "";
|
|
1242
|
+
return `
|
|
1219
1243
|
<tr>
|
|
1220
1244
|
<td class="mono"><a href="/jobs/${job.id}" class="job-link">${job.id}</a></td>
|
|
1221
|
-
<td
|
|
1245
|
+
<td>${job.task_name}${argsHtml}</td>
|
|
1222
1246
|
<td>${job.queue}</td>
|
|
1223
1247
|
<td><span class="badge ${job.state}">${job.state}</span></td>
|
|
1224
1248
|
<td>${job.attempt}/${job.max_attempts}</td>
|
|
1225
1249
|
<td class="mono">${formatTime(job.inserted_at)}</td>
|
|
1226
1250
|
</tr>
|
|
1227
|
-
|
|
1228
|
-
)
|
|
1251
|
+
`;
|
|
1252
|
+
})
|
|
1229
1253
|
.join("");
|
|
1230
1254
|
}
|
|
1231
1255
|
|
|
@@ -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.
|
|
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.
|
|
822
|
+
job.result,
|
|
762
823
|
);
|
|
763
824
|
}
|
|
764
825
|
|
|
@@ -815,10 +876,21 @@
|
|
|
815
876
|
|
|
816
877
|
familyData = await res.json();
|
|
817
878
|
|
|
818
|
-
//
|
|
819
|
-
|
|
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.
|
|
1218
|
-
const
|
|
1219
|
-
typeof job.
|
|
1220
|
-
? job.
|
|
1221
|
-
: JSON.stringify(job.
|
|
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
|
-
|
|
1438
|
+
resultStr.length > 30
|
|
1439
|
+
? resultStr.slice(0, 30) + "..."
|
|
1440
|
+
: resultStr;
|
|
1224
1441
|
}
|
|
1225
1442
|
|
|
1226
1443
|
return `
|
|
@@ -159,7 +159,7 @@ async def api_jobs(request: web.Request) -> web.Response:
|
|
|
159
159
|
parent_id, deps,
|
|
160
160
|
state, priority, scheduled_at,
|
|
161
161
|
attempted_at, completed_at, attempt, max_attempts,
|
|
162
|
-
|
|
162
|
+
result, errors, tags,
|
|
163
163
|
inserted_at, updated_at
|
|
164
164
|
FROM fairchild_jobs
|
|
165
165
|
{where}
|
|
@@ -316,7 +316,7 @@ async def api_job_detail(request: web.Request) -> web.Response:
|
|
|
316
316
|
parent_id, deps,
|
|
317
317
|
state, priority, scheduled_at,
|
|
318
318
|
attempted_at, completed_at, attempt, max_attempts,
|
|
319
|
-
|
|
319
|
+
result, errors, tags,
|
|
320
320
|
inserted_at, updated_at
|
|
321
321
|
FROM fairchild_jobs
|
|
322
322
|
WHERE id = $1
|
|
@@ -382,15 +382,15 @@ async def api_job_family(request: web.Request) -> web.Response:
|
|
|
382
382
|
# Get all descendants from the root
|
|
383
383
|
family_query = """
|
|
384
384
|
WITH RECURSIVE family AS (
|
|
385
|
-
SELECT id, task_name, parent_id, state, deps,
|
|
386
|
-
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
|
|
387
387
|
FROM fairchild_jobs
|
|
388
388
|
WHERE id = $1
|
|
389
389
|
|
|
390
390
|
UNION ALL
|
|
391
391
|
|
|
392
|
-
SELECT j.id, j.task_name, j.parent_id, j.state, j.deps, j.
|
|
393
|
-
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
|
|
394
394
|
FROM fairchild_jobs j
|
|
395
395
|
INNER JOIN family f ON j.parent_id = f.id
|
|
396
396
|
)
|
|
@@ -409,6 +409,7 @@ async def api_job_family(request: web.Request) -> web.Response:
|
|
|
409
409
|
job["completed_at"] = (
|
|
410
410
|
job["completed_at"].isoformat() if job["completed_at"] else None
|
|
411
411
|
)
|
|
412
|
+
job["updated_at"] = job["updated_at"].isoformat() if job["updated_at"] else None
|
|
412
413
|
jobs.append(job)
|
|
413
414
|
|
|
414
415
|
return web.json_response({"root_id": str(root_id), "jobs": jobs})
|
|
@@ -10,7 +10,6 @@ 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
13
|
from fairchild.task import get_task, get_task_schemas
|
|
15
14
|
|
|
16
15
|
|
|
@@ -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
|
|
122
|
+
# Fetch the result from the completed job
|
|
124
123
|
query = """
|
|
125
|
-
SELECT
|
|
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
|
-
#
|
|
184
|
-
|
|
185
|
-
if
|
|
186
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
226
|
+
result = $2,
|
|
224
227
|
updated_at = now()
|
|
225
228
|
WHERE id = $1
|
|
226
229
|
"""
|
|
227
230
|
|
|
228
|
-
|
|
229
|
-
await self.fairchild._pool.execute(query, job.id,
|
|
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,
|
|
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
|
|
254
|
+
# Store the result value so we can use it when completing later
|
|
252
255
|
query = """
|
|
253
256
|
UPDATE fairchild_jobs
|
|
254
|
-
SET
|
|
257
|
+
SET result = $2,
|
|
255
258
|
updated_at = now()
|
|
256
259
|
WHERE id = $1
|
|
257
260
|
"""
|
|
258
|
-
|
|
259
|
-
await self.fairchild._pool.execute(query, job.id,
|
|
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)
|
|
@@ -7,7 +7,6 @@ fairchild/context.py
|
|
|
7
7
|
fairchild/fairchild.py
|
|
8
8
|
fairchild/future.py
|
|
9
9
|
fairchild/job.py
|
|
10
|
-
fairchild/record.py
|
|
11
10
|
fairchild/task.py
|
|
12
11
|
fairchild/ui.py
|
|
13
12
|
fairchild/worker.py
|
|
@@ -24,11 +23,11 @@ fairchild/db/migrations/002_add_parent_id.sql
|
|
|
24
23
|
fairchild/db/migrations/003_remove_workflows.sql
|
|
25
24
|
fairchild/db/migrations/004_add_workers.sql
|
|
26
25
|
fairchild/db/migrations/005_add_worker_tasks.sql
|
|
26
|
+
fairchild/db/migrations/006_rename_recorded_to_result.sql
|
|
27
27
|
fairchild/templates/dashboard.html
|
|
28
28
|
fairchild/templates/job.html
|
|
29
29
|
tests/test_cli.py
|
|
30
30
|
tests/test_integration.py
|
|
31
31
|
tests/test_job.py
|
|
32
|
-
tests/test_record.py
|
|
33
32
|
tests/test_task.py
|
|
34
33
|
tests/test_web_ui.py
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""Integration tests for the fairchild CLI."""
|
|
2
|
+
|
|
2
3
|
import subprocess
|
|
3
4
|
import sys
|
|
4
5
|
import os
|
|
@@ -8,29 +9,33 @@ def test_fairchild_run_command():
|
|
|
8
9
|
"""Test the 'fairchild run' command with orchestrator_n example."""
|
|
9
10
|
# Set PYTHONPATH to include current directory
|
|
10
11
|
env = os.environ.copy()
|
|
11
|
-
env[
|
|
12
|
-
|
|
12
|
+
env["PYTHONPATH"] = "."
|
|
13
|
+
|
|
13
14
|
# Get the repository root (assuming tests/ is in repo root)
|
|
14
15
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
-
|
|
16
|
+
|
|
16
17
|
# Run the command
|
|
17
18
|
result = subprocess.run(
|
|
18
19
|
[
|
|
19
|
-
sys.executable,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
sys.executable,
|
|
21
|
+
"-m",
|
|
22
|
+
"fairchild.cli",
|
|
23
|
+
"run",
|
|
24
|
+
"-i",
|
|
25
|
+
"examples.tasks",
|
|
26
|
+
"examples.tasks.orchestrator_n",
|
|
27
|
+
"-a",
|
|
28
|
+
"n_items=2",
|
|
24
29
|
],
|
|
25
30
|
cwd=repo_root,
|
|
26
31
|
capture_output=True,
|
|
27
32
|
text=True,
|
|
28
|
-
env=env
|
|
33
|
+
env=env,
|
|
29
34
|
)
|
|
30
|
-
|
|
35
|
+
|
|
31
36
|
# Check it ran successfully
|
|
32
37
|
assert result.returncode == 0, f"Command failed with: {result.stderr}"
|
|
33
|
-
|
|
38
|
+
|
|
34
39
|
# Check expected output
|
|
35
40
|
output = result.stdout
|
|
36
41
|
assert "Invoking examples.tasks.orchestrator_n..." in output
|
|
@@ -38,32 +43,36 @@ def test_fairchild_run_command():
|
|
|
38
43
|
assert "0 * 2 = 0" in output
|
|
39
44
|
assert "1 * 2 = 2" in output
|
|
40
45
|
assert "Summing [0, 2] = 2" in output
|
|
41
|
-
assert "Result:
|
|
46
|
+
assert "Result: {'spawned': 3}" in output
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
def test_fairchild_run_hello():
|
|
45
50
|
"""Test the 'fairchild run' command with a simple hello task."""
|
|
46
51
|
env = os.environ.copy()
|
|
47
|
-
env[
|
|
48
|
-
|
|
52
|
+
env["PYTHONPATH"] = "."
|
|
53
|
+
|
|
49
54
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
-
|
|
55
|
+
|
|
51
56
|
result = subprocess.run(
|
|
52
57
|
[
|
|
53
|
-
sys.executable,
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
sys.executable,
|
|
59
|
+
"-m",
|
|
60
|
+
"fairchild.cli",
|
|
61
|
+
"run",
|
|
62
|
+
"-i",
|
|
63
|
+
"examples.tasks",
|
|
64
|
+
"examples.tasks.hello",
|
|
65
|
+
"-a",
|
|
66
|
+
"name=World",
|
|
58
67
|
],
|
|
59
68
|
cwd=repo_root,
|
|
60
69
|
capture_output=True,
|
|
61
70
|
text=True,
|
|
62
|
-
env=env
|
|
71
|
+
env=env,
|
|
63
72
|
)
|
|
64
|
-
|
|
73
|
+
|
|
65
74
|
assert result.returncode == 0, f"Command failed with: {result.stderr}"
|
|
66
|
-
|
|
75
|
+
|
|
67
76
|
output = result.stdout
|
|
68
77
|
assert "Invoking examples.tasks.hello..." in output
|
|
69
78
|
assert "Hello, World!" in output
|
|
@@ -74,27 +83,32 @@ def test_fairchild_run_hello():
|
|
|
74
83
|
def test_fairchild_run_add():
|
|
75
84
|
"""Test the 'fairchild run' command with add task."""
|
|
76
85
|
env = os.environ.copy()
|
|
77
|
-
env[
|
|
78
|
-
|
|
86
|
+
env["PYTHONPATH"] = "."
|
|
87
|
+
|
|
79
88
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
80
|
-
|
|
89
|
+
|
|
81
90
|
result = subprocess.run(
|
|
82
91
|
[
|
|
83
|
-
sys.executable,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
sys.executable,
|
|
93
|
+
"-m",
|
|
94
|
+
"fairchild.cli",
|
|
95
|
+
"run",
|
|
96
|
+
"-i",
|
|
97
|
+
"examples.tasks",
|
|
98
|
+
"examples.tasks.add",
|
|
99
|
+
"-a",
|
|
100
|
+
"a=5",
|
|
101
|
+
"-a",
|
|
102
|
+
"b=3",
|
|
89
103
|
],
|
|
90
104
|
cwd=repo_root,
|
|
91
105
|
capture_output=True,
|
|
92
106
|
text=True,
|
|
93
|
-
env=env
|
|
107
|
+
env=env,
|
|
94
108
|
)
|
|
95
|
-
|
|
109
|
+
|
|
96
110
|
assert result.returncode == 0, f"Command failed with: {result.stderr}"
|
|
97
|
-
|
|
111
|
+
|
|
98
112
|
output = result.stdout
|
|
99
113
|
assert "Invoking examples.tasks.add..." in output
|
|
100
114
|
assert "5 + 3 = 8" in output
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
"""Tests for task registration and task decorator."""
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from fairchild import task
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
def test_task_decorator():
|
|
6
7
|
"""Test that the @task decorator creates a Task object."""
|
|
7
|
-
|
|
8
|
+
|
|
8
9
|
@task(queue="test", priority=1)
|
|
9
|
-
def simple_task(x: int) ->
|
|
10
|
-
return
|
|
11
|
-
|
|
10
|
+
def simple_task(x: int) -> dict:
|
|
11
|
+
return {"result": x * 2}
|
|
12
|
+
|
|
12
13
|
# Check that task has expected attributes
|
|
13
14
|
assert simple_task.queue == "test"
|
|
14
15
|
assert simple_task.priority == 1
|
|
@@ -16,20 +17,15 @@ def test_task_decorator():
|
|
|
16
17
|
# Task name includes full module path
|
|
17
18
|
assert "simple_task" in simple_task.name
|
|
18
19
|
assert simple_task.name.endswith(".simple_task")
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
|
|
21
22
|
def test_task_with_custom_settings():
|
|
22
23
|
"""Test task with all custom settings."""
|
|
23
|
-
|
|
24
|
-
@task(
|
|
25
|
-
queue="custom",
|
|
26
|
-
priority=9,
|
|
27
|
-
max_attempts=5,
|
|
28
|
-
tags=["test", "custom"]
|
|
29
|
-
)
|
|
24
|
+
|
|
25
|
+
@task(queue="custom", priority=9, max_attempts=5, tags=["test", "custom"])
|
|
30
26
|
def custom_task() -> None:
|
|
31
27
|
pass
|
|
32
|
-
|
|
28
|
+
|
|
33
29
|
assert custom_task.queue == "custom"
|
|
34
30
|
assert custom_task.priority == 9
|
|
35
31
|
assert custom_task.max_attempts == 5
|
|
@@ -38,25 +34,24 @@ def test_task_with_custom_settings():
|
|
|
38
34
|
|
|
39
35
|
def test_task_execution_without_worker():
|
|
40
36
|
"""Test that tasks can be called directly when not in a worker context."""
|
|
41
|
-
|
|
37
|
+
|
|
42
38
|
@task()
|
|
43
|
-
def add_numbers(a: int, b: int) ->
|
|
44
|
-
return
|
|
45
|
-
|
|
39
|
+
def add_numbers(a: int, b: int) -> dict:
|
|
40
|
+
return {"sum": a + b}
|
|
41
|
+
|
|
46
42
|
# When called directly (not in a worker), should execute the function
|
|
47
43
|
result = add_numbers(5, 3)
|
|
48
|
-
|
|
49
|
-
# Record should be unwrapped in direct calls
|
|
44
|
+
|
|
50
45
|
assert result == {"sum": 8}
|
|
51
46
|
|
|
52
47
|
|
|
53
48
|
def test_task_name_derivation():
|
|
54
49
|
"""Test that task names are derived from module and function."""
|
|
55
|
-
|
|
50
|
+
|
|
56
51
|
@task()
|
|
57
52
|
def my_function():
|
|
58
53
|
pass
|
|
59
|
-
|
|
54
|
+
|
|
60
55
|
# Should include module and function name
|
|
61
56
|
assert "my_function" in my_function.name
|
|
62
57
|
assert my_function.name.endswith(".my_function")
|
|
@@ -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,40 +0,0 @@
|
|
|
1
|
-
"""Tests for Record class."""
|
|
2
|
-
from fairchild import Record
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def test_record_creation():
|
|
6
|
-
"""Test creating a Record with a value."""
|
|
7
|
-
record = Record({"key": "value"})
|
|
8
|
-
|
|
9
|
-
assert record.value == {"key": "value"}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_record_with_dict():
|
|
13
|
-
"""Test Record with dictionary value."""
|
|
14
|
-
data = {"name": "test", "count": 42}
|
|
15
|
-
record = Record(data)
|
|
16
|
-
|
|
17
|
-
assert record.value == data
|
|
18
|
-
assert record.value["name"] == "test"
|
|
19
|
-
assert record.value["count"] == 42
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_record_with_list():
|
|
23
|
-
"""Test Record with list value."""
|
|
24
|
-
data = [1, 2, 3, 4, 5]
|
|
25
|
-
record = Record(data)
|
|
26
|
-
|
|
27
|
-
assert record.value == data
|
|
28
|
-
assert len(record.value) == 5
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def test_record_with_primitive():
|
|
32
|
-
"""Test Record with primitive value."""
|
|
33
|
-
record_int = Record(42)
|
|
34
|
-
assert record_int.value == 42
|
|
35
|
-
|
|
36
|
-
record_str = Record("hello")
|
|
37
|
-
assert record_str.value == "hello"
|
|
38
|
-
|
|
39
|
-
record_bool = Record(True)
|
|
40
|
-
assert record_bool.value is True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|