rushti 2.2.3__tar.gz → 2.3.0__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.
- {rushti-2.2.3/src/rushti.egg-info → rushti-2.3.0}/PKG-INFO +1 -1
- {rushti-2.2.3 → rushti-2.3.0}/pyproject.toml +1 -1
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/__init__.py +1 -1
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/cli.py +5 -2
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/dashboard.py +61 -17
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/execution.py +220 -12
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/logging.py +17 -6
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/messages.py +25 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/optimizer.py +4 -2
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/parsing.py +19 -8
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/dynamodb.py +9 -2
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/repository.py +1 -0
- rushti-2.3.0/src/rushti/stats/signature.py +42 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/sqlite.py +29 -11
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/task.py +66 -14
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/taskfile.py +87 -10
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/taskfile_ops.py +21 -9
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/templates/visualization.html +20 -5
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/tm1_integration.py +26 -7
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/tm1_objects.py +10 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/visualization_template.py +20 -5
- {rushti-2.2.3 → rushti-2.3.0/src/rushti.egg-info}/PKG-INFO +1 -1
- rushti-2.2.3/src/rushti/stats/signature.py +0 -29
- {rushti-2.2.3 → rushti-2.3.0}/LICENSE +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/README.md +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/setup.cfg +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/_shlex_utils.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/app_paths.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/checkpoint.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/__init__.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/build.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/db.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/resume.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/__init__.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/analyze.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/export.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/list.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/optimize.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/visualize.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/__init__.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/expand.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/export.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/push.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/validate.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/visualize.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/contention_analyzer.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/dag.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/db_admin.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/exclusive.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/logging_setup.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/optimization_report.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/results_writer.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/settings.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/__init__.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/paths.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/templates/__init__.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/tm1_build.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti/utils.py +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/SOURCES.txt +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/dependency_links.txt +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/entry_points.txt +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/requires.txt +0 -0
- {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/top_level.txt +0 -0
|
@@ -5,7 +5,7 @@ This package provides tools for executing TM1 TI processes in parallel
|
|
|
5
5
|
with dependency management, checkpoint/resume support, and execution logging.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "2.
|
|
8
|
+
__version__ = "2.3.0"
|
|
9
9
|
__app_name__ = "RushTI"
|
|
10
10
|
|
|
11
11
|
# Core exports for programmatic use
|
|
@@ -249,11 +249,14 @@ Examples:
|
|
|
249
249
|
%(prog)s -t tasks.txt -w 4 -r 2 -o results.csv
|
|
250
250
|
%(prog)s --tasks tasks.json --max-workers 8 --retries 3
|
|
251
251
|
%(prog)s --tm1-instance tm1srv01 --workflow Sample --max-workers 4
|
|
252
|
+
%(prog)s --tm1-instance tm1srv01 --workflow Sample_Optimal_Mode --mode opt
|
|
252
253
|
|
|
253
254
|
Configuration:
|
|
254
255
|
Default values can be set in settings.ini (see settings.ini.template).
|
|
255
256
|
Settings precedence: CLI args > JSON task file > settings.ini > defaults
|
|
256
|
-
File
|
|
257
|
+
File sources auto-detect the mode (--mode is ignored). Cube reads
|
|
258
|
+
(--tm1-instance) cannot, and default to norm; pass --mode opt to honour
|
|
259
|
+
the 'predecessors' measure.
|
|
257
260
|
""",
|
|
258
261
|
)
|
|
259
262
|
|
|
@@ -286,7 +289,7 @@ Configuration:
|
|
|
286
289
|
dest="execution_mode",
|
|
287
290
|
choices=["norm", "opt"],
|
|
288
291
|
default=None,
|
|
289
|
-
help="
|
|
292
|
+
help="Execution mode: norm or opt. Auto-detected (and ignored) for file sources. For cube reads (--tm1-instance) it defaults to norm; pass 'opt' to honour the 'predecessors' measure.",
|
|
290
293
|
)
|
|
291
294
|
|
|
292
295
|
parser.add_argument(
|
|
@@ -234,16 +234,26 @@ def _prepare_dashboard_data(
|
|
|
234
234
|
}
|
|
235
235
|
)
|
|
236
236
|
|
|
237
|
-
# Build per-task aggregate data (across all runs)
|
|
237
|
+
# Build per-task aggregate data (across all runs). Each row carries
|
|
238
|
+
# both ``process`` (may be empty for chore rows) and ``chore`` so the
|
|
239
|
+
# dashboard can render a unified "Task target" column with a kind
|
|
240
|
+
# indicator. ``task_target``/``task_kind`` are the canonical fields
|
|
241
|
+
# for display; the raw kind fields stay populated for backward
|
|
242
|
+
# compatibility with downstream consumers.
|
|
238
243
|
task_data: Dict[str, Dict[str, Any]] = {}
|
|
239
244
|
for tr in task_results:
|
|
240
245
|
sig = tr["task_signature"]
|
|
246
|
+
process_val = tr.get("process") or ""
|
|
247
|
+
chore_val = tr.get("chore") or ""
|
|
241
248
|
if sig not in task_data:
|
|
242
249
|
task_data[sig] = {
|
|
243
250
|
"task_signature": sig,
|
|
244
251
|
"task_id": tr["task_id"],
|
|
245
252
|
"instance": tr["instance"],
|
|
246
|
-
"process":
|
|
253
|
+
"process": process_val,
|
|
254
|
+
"chore": chore_val,
|
|
255
|
+
"task_target": chore_val or process_val,
|
|
256
|
+
"task_kind": "chore" if chore_val else "process",
|
|
247
257
|
"durations": [],
|
|
248
258
|
"successes": 0,
|
|
249
259
|
"total": 0,
|
|
@@ -263,6 +273,9 @@ def _prepare_dashboard_data(
|
|
|
263
273
|
"task_id": data["task_id"],
|
|
264
274
|
"instance": data["instance"],
|
|
265
275
|
"process": data["process"],
|
|
276
|
+
"chore": data["chore"],
|
|
277
|
+
"task_target": data["task_target"],
|
|
278
|
+
"task_kind": data["task_kind"],
|
|
266
279
|
"executions": data["total"],
|
|
267
280
|
"success_rate": (
|
|
268
281
|
round(data["successes"] / data["total"] * 100, 1) if data["total"] > 0 else 0
|
|
@@ -290,10 +303,15 @@ def _prepare_dashboard_data(
|
|
|
290
303
|
outliers = []
|
|
291
304
|
for tr in task_results:
|
|
292
305
|
if tr["duration_seconds"] is not None:
|
|
306
|
+
process_val = tr.get("process") or ""
|
|
307
|
+
chore_val = tr.get("chore") or ""
|
|
293
308
|
outliers.append(
|
|
294
309
|
{
|
|
295
310
|
"task_id": tr["task_id"],
|
|
296
|
-
"process":
|
|
311
|
+
"process": process_val,
|
|
312
|
+
"chore": chore_val,
|
|
313
|
+
"task_target": chore_val or process_val,
|
|
314
|
+
"task_kind": "chore" if chore_val else "process",
|
|
297
315
|
"instance": tr["instance"],
|
|
298
316
|
"run_id": tr["run_id"],
|
|
299
317
|
"duration": round(tr["duration_seconds"], 2),
|
|
@@ -308,10 +326,15 @@ def _prepare_dashboard_data(
|
|
|
308
326
|
failures = []
|
|
309
327
|
for tr in task_results:
|
|
310
328
|
if tr["status"] != "Success":
|
|
329
|
+
process_val = tr.get("process") or ""
|
|
330
|
+
chore_val = tr.get("chore") or ""
|
|
311
331
|
failures.append(
|
|
312
332
|
{
|
|
313
333
|
"task_id": tr["task_id"],
|
|
314
|
-
"process":
|
|
334
|
+
"process": process_val,
|
|
335
|
+
"chore": chore_val,
|
|
336
|
+
"task_target": chore_val or process_val,
|
|
337
|
+
"task_kind": "chore" if chore_val else "process",
|
|
315
338
|
"instance": tr["instance"],
|
|
316
339
|
"run_id": tr["run_id"],
|
|
317
340
|
"duration": round(tr["duration_seconds"], 2) if tr["duration_seconds"] else 0,
|
|
@@ -322,13 +345,18 @@ def _prepare_dashboard_data(
|
|
|
322
345
|
# Slim task_results for JS (only fields needed for interactive filtering)
|
|
323
346
|
slim_task_results = []
|
|
324
347
|
for tr in task_results:
|
|
348
|
+
process_val = tr.get("process") or ""
|
|
349
|
+
chore_val = tr.get("chore") or ""
|
|
325
350
|
slim_task_results.append(
|
|
326
351
|
{
|
|
327
352
|
"run_id": tr["run_id"],
|
|
328
353
|
"task_id": tr["task_id"],
|
|
329
354
|
"task_signature": tr["task_signature"],
|
|
330
355
|
"instance": tr["instance"],
|
|
331
|
-
"process":
|
|
356
|
+
"process": process_val,
|
|
357
|
+
"chore": chore_val,
|
|
358
|
+
"task_target": chore_val or process_val,
|
|
359
|
+
"task_kind": "chore" if chore_val else "process",
|
|
332
360
|
"status": tr["status"],
|
|
333
361
|
"duration_seconds": tr["duration_seconds"],
|
|
334
362
|
"error_message": tr.get("error_message"),
|
|
@@ -578,6 +606,11 @@ def generate_dashboard(
|
|
|
578
606
|
.status-partial {{ color: #D97706; font-weight: 600; }}
|
|
579
607
|
.high-cv {{ background: #FFF7ED; }}
|
|
580
608
|
.duration-slow {{ color: #DC2626; font-weight: 600; }}
|
|
609
|
+
.kind-tag {{
|
|
610
|
+
display: inline-block; padding: 1px 5px; border-radius: 4px;
|
|
611
|
+
font-size: 0.7rem; font-weight: 700; color: #475569;
|
|
612
|
+
background: #E2E8F0; margin-right: 4px;
|
|
613
|
+
}}
|
|
581
614
|
|
|
582
615
|
/* Accordion */
|
|
583
616
|
.accordion {{ margin-bottom: 20px; }}
|
|
@@ -752,7 +785,7 @@ def generate_dashboard(
|
|
|
752
785
|
<thead><tr>
|
|
753
786
|
<th onclick="sortTaskTable(0)">Task ID <span class="sort-arrow"></span></th>
|
|
754
787
|
<th onclick="sortTaskTable(1)">Instance <span class="sort-arrow"></span></th>
|
|
755
|
-
<th onclick="sortTaskTable(2)">
|
|
788
|
+
<th onclick="sortTaskTable(2)">Task target <span class="sort-arrow"></span></th>
|
|
756
789
|
<th onclick="sortTaskTable(3)">Avg (s) <span class="sort-arrow"></span></th>
|
|
757
790
|
<th onclick="sortTaskTable(4)">Min (s) <span class="sort-arrow"></span></th>
|
|
758
791
|
<th onclick="sortTaskTable(5)">Max (s) <span class="sort-arrow"></span></th>
|
|
@@ -768,7 +801,7 @@ def generate_dashboard(
|
|
|
768
801
|
<h3>Top 10 Slowest Executions <span class="help-icon">?<span class="help-tip">How to read: These are the 10 longest-running individual task executions across the selected runs. The "vs Median" column tells you how many times slower each one was compared to the typical task — a high multiplier (e.g., 5×) means that execution was significantly slower than usual and may be worth investigating. Check whether the same task appears multiple times — if so, it is a consistent bottleneck.</span></span></h3>
|
|
769
802
|
<table id="outlierTable">
|
|
770
803
|
<thead><tr>
|
|
771
|
-
<th>Task ID</th><th>
|
|
804
|
+
<th>Task ID</th><th>Task target</th><th>Instance</th>
|
|
772
805
|
<th>Run</th><th>Duration (s)</th><th>vs Median</th><th>Status</th>
|
|
773
806
|
</tr></thead>
|
|
774
807
|
<tbody id="outlierTableBody"></tbody>
|
|
@@ -779,7 +812,7 @@ def generate_dashboard(
|
|
|
779
812
|
<h3>Failed Processes <span class="help-icon">?<span class="help-tip">How to read: Every task execution that did not succeed is listed here. Look for patterns — does the same task fail repeatedly across runs? That points to a systemic issue. If failures are scattered across different tasks, the problem may be environmental (server load, connectivity). Hover over the Error column to see the full error message for each failure.</span></span></h3>
|
|
780
813
|
<table id="failureTable">
|
|
781
814
|
<thead><tr>
|
|
782
|
-
<th>Task ID</th><th>
|
|
815
|
+
<th>Task ID</th><th>Task target</th><th>Instance</th>
|
|
783
816
|
<th>Run</th><th>Duration (s)</th><th>Error</th>
|
|
784
817
|
</tr></thead>
|
|
785
818
|
<tbody id="failureTableBody"></tbody>
|
|
@@ -1260,14 +1293,14 @@ def generate_dashboard(
|
|
|
1260
1293
|
// Also store a lookup for tooltip labels
|
|
1261
1294
|
const sigLabel = {{}};
|
|
1262
1295
|
relevant.forEach(tr => {{
|
|
1263
|
-
if (!sigLabel[tr.task_signature]) sigLabel[tr.task_signature] = tr.process;
|
|
1296
|
+
if (!sigLabel[tr.task_signature]) sigLabel[tr.task_signature] = tr.task_target || tr.process || tr.chore;
|
|
1264
1297
|
}});
|
|
1265
1298
|
|
|
1266
1299
|
const datasets = runs.map((run, idx) => {{
|
|
1267
1300
|
const runTasks = relevant.filter(tr => tr.run_id === run.run_id);
|
|
1268
1301
|
return {{
|
|
1269
1302
|
label: formatDate(run.start_time),
|
|
1270
|
-
data: runTasks.map(t => ({{ x: sigIndex[t.task_signature], y: t.duration_seconds || 0, _sig: t.task_signature, _task: t.task_id, _proc: t.process }})),
|
|
1303
|
+
data: runTasks.map(t => ({{ x: sigIndex[t.task_signature], y: t.duration_seconds || 0, _sig: t.task_signature, _task: t.task_id, _proc: t.task_target || t.process || t.chore }})),
|
|
1271
1304
|
backgroundColor: RUN_COLORS[idx % RUN_COLORS.length] + '80',
|
|
1272
1305
|
borderColor: RUN_COLORS[idx % RUN_COLORS.length],
|
|
1273
1306
|
pointRadius: 3,
|
|
@@ -1470,7 +1503,10 @@ def generate_dashboard(
|
|
|
1470
1503
|
const sig = tr.task_signature;
|
|
1471
1504
|
if (!taskData[sig]) {{
|
|
1472
1505
|
taskData[sig] = {{
|
|
1473
|
-
task_id: tr.task_id, instance: tr.instance,
|
|
1506
|
+
task_id: tr.task_id, instance: tr.instance,
|
|
1507
|
+
process: tr.process, chore: tr.chore,
|
|
1508
|
+
task_target: tr.task_target || tr.process || tr.chore,
|
|
1509
|
+
task_kind: tr.task_kind || (tr.chore ? 'chore' : 'process'),
|
|
1474
1510
|
durations: [], successes: 0, total: 0
|
|
1475
1511
|
}};
|
|
1476
1512
|
}}
|
|
@@ -1492,7 +1528,7 @@ def generate_dashboard(
|
|
|
1492
1528
|
}}
|
|
1493
1529
|
|
|
1494
1530
|
function applyTaskSort(rows) {{
|
|
1495
|
-
const fields = ['task_id', 'instance', '
|
|
1531
|
+
const fields = ['task_id', 'instance', 'task_target', 'avg', 'mn', 'mx', 'stdDev', 'successRate', 'total'];
|
|
1496
1532
|
const field = fields[taskTableSortCol];
|
|
1497
1533
|
rows.sort((a, b) => {{
|
|
1498
1534
|
const av = a[field], bv = b[field];
|
|
@@ -1556,10 +1592,12 @@ def generate_dashboard(
|
|
|
1556
1592
|
tbody.innerHTML = pageRows.map(r => {{
|
|
1557
1593
|
const cvClass = r.cv > 0.5 ? 'high-cv' : '';
|
|
1558
1594
|
const statusCls = r.successRate < 100 ? 'status-fail' : 'status-success';
|
|
1595
|
+
const kindTag = r.task_kind === 'chore' ? '[C]' : '[P]';
|
|
1596
|
+
const target = r.task_target || r.process || r.chore || '';
|
|
1559
1597
|
return `<tr class="${{cvClass}}">
|
|
1560
1598
|
<td class="mono">${{r.task_id}}</td>
|
|
1561
1599
|
<td>${{r.instance}}</td>
|
|
1562
|
-
<td>${{
|
|
1600
|
+
<td><span class="kind-tag">${{kindTag}}</span> ${{target}}</td>
|
|
1563
1601
|
<td class="mono">${{r.avg.toFixed(2)}}</td>
|
|
1564
1602
|
<td class="mono">${{r.mn.toFixed(2)}}</td>
|
|
1565
1603
|
<td class="mono">${{r.mx.toFixed(2)}}</td>
|
|
@@ -1585,9 +1623,11 @@ def generate_dashboard(
|
|
|
1585
1623
|
tbody.innerHTML = sorted.map(o => {{
|
|
1586
1624
|
const statusCls = o.status === 'Success' ? 'status-success' : 'status-fail';
|
|
1587
1625
|
const vsMedian = o.duration_seconds - median;
|
|
1626
|
+
const kindTag = o.task_kind === 'chore' || o.chore ? '[C]' : '[P]';
|
|
1627
|
+
const target = o.task_target || o.process || o.chore || '';
|
|
1588
1628
|
return `<tr>
|
|
1589
1629
|
<td class="mono">${{o.task_id}}</td>
|
|
1590
|
-
<td>${{
|
|
1630
|
+
<td><span class="kind-tag">${{kindTag}}</span> ${{target}}</td>
|
|
1591
1631
|
<td>${{o.instance}}</td>
|
|
1592
1632
|
<td class="mono">${{o.run_id}}</td>
|
|
1593
1633
|
<td class="mono duration-slow">${{o.duration_seconds.toFixed(2)}}</td>
|
|
@@ -1613,15 +1653,19 @@ def generate_dashboard(
|
|
|
1613
1653
|
}}
|
|
1614
1654
|
|
|
1615
1655
|
panel.style.display = 'block';
|
|
1616
|
-
tbody.innerHTML = filtered.map(f =>
|
|
1656
|
+
tbody.innerHTML = filtered.map(f => {{
|
|
1657
|
+
const kindTag = f.task_kind === 'chore' || f.chore ? '[C]' : '[P]';
|
|
1658
|
+
const target = f.task_target || f.process || f.chore || '';
|
|
1659
|
+
return `<tr>
|
|
1617
1660
|
<td class="mono">${{f.task_id}}</td>
|
|
1618
|
-
<td>${{
|
|
1661
|
+
<td><span class="kind-tag">${{kindTag}}</span> ${{target}}</td>
|
|
1619
1662
|
<td>${{f.instance}}</td>
|
|
1620
1663
|
<td class="mono">${{f.run_id}}</td>
|
|
1621
1664
|
<td class="mono">${{f.duration_seconds != null ? f.duration_seconds.toFixed(2) : '-'}}</td>
|
|
1622
1665
|
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
|
1623
1666
|
title="${{(f.error_message || '').replace(/"/g, '"')}}">${{f.error_message || '-'}}</td>
|
|
1624
|
-
</tr
|
|
1667
|
+
</tr>`;
|
|
1668
|
+
}}).join('');
|
|
1625
1669
|
}}
|
|
1626
1670
|
|
|
1627
1671
|
function updateConfigDetails(runs) {{
|
|
@@ -24,6 +24,7 @@ from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
|
24
24
|
|
|
25
25
|
from TM1py import TM1Service
|
|
26
26
|
from TM1py.Exceptions import TM1pyTimeout
|
|
27
|
+
from TM1py.Objects import Chore
|
|
27
28
|
|
|
28
29
|
from rushti.task import Task, OptimizedTask
|
|
29
30
|
from rushti.dag import DAG
|
|
@@ -40,6 +41,11 @@ from rushti.messages import (
|
|
|
40
41
|
MSG_PROCESS_NOT_EXISTS,
|
|
41
42
|
MSG_PROCESS_PARAMS_INCORRECT,
|
|
42
43
|
MSG_PROCESS_TIMEOUT,
|
|
44
|
+
MSG_CHORE_EXECUTE,
|
|
45
|
+
MSG_CHORE_SUCCESS,
|
|
46
|
+
MSG_CHORE_FAIL,
|
|
47
|
+
MSG_CHORE_NOT_EXISTS,
|
|
48
|
+
MSG_CHORE_REQUIRES_SINGLE_COMMIT,
|
|
43
49
|
)
|
|
44
50
|
from rushti.parsing import get_instances_from_tasks_file
|
|
45
51
|
from rushti.exclusive import build_session_context
|
|
@@ -99,12 +105,17 @@ def _collect_task_stats(
|
|
|
99
105
|
return
|
|
100
106
|
|
|
101
107
|
with ctx.stats_data_lock:
|
|
108
|
+
# ``process`` is NOT NULL in SQLite; chore tasks write the empty
|
|
109
|
+
# string so the column constraint stays satisfied without making
|
|
110
|
+
# it nullable. The kind is unambiguous: rows with a non-empty
|
|
111
|
+
# ``chore`` are chore tasks, rows without are processes.
|
|
102
112
|
stats_entry = {
|
|
103
113
|
"run_id": ctx.execution_logger.run_id if ctx.execution_logger else "",
|
|
104
114
|
"workflow": ctx.execution_logger.workflow if ctx.execution_logger else None,
|
|
105
115
|
"task_id": task.id,
|
|
106
116
|
"instance": task.instance_name,
|
|
107
|
-
"process": task.process_name,
|
|
117
|
+
"process": task.process_name or "",
|
|
118
|
+
"chore": getattr(task, "chore_name", None),
|
|
108
119
|
"parameters": task.parameters if isinstance(task.parameters, dict) else {},
|
|
109
120
|
"success": success,
|
|
110
121
|
"start_time": start_time,
|
|
@@ -221,6 +232,39 @@ def setup_tm1_services(
|
|
|
221
232
|
return tm1_services, tm1_preserve_connections
|
|
222
233
|
|
|
223
234
|
|
|
235
|
+
def execute_chore_with_retries(tm1: TM1Service, task: Task, retries: int):
|
|
236
|
+
"""Execute a TM1 chore with retry support.
|
|
237
|
+
|
|
238
|
+
Mirrors :func:`execute_process_with_retries` for the chore path.
|
|
239
|
+
Chore execution at the TM1 API boundary is binary: HTTP 204 = success
|
|
240
|
+
(no body to parse), any exception (typically HTTP 500 with a
|
|
241
|
+
"Chore execution failed" body) = failure.
|
|
242
|
+
|
|
243
|
+
Returns a 4-tuple shaped like the process variant for symmetry — the
|
|
244
|
+
``status`` slot carries a short human-readable summary, ``error_log_file``
|
|
245
|
+
is always empty (chores write no per-execution error file), and
|
|
246
|
+
``attempts`` is the 0-based attempt index of the result.
|
|
247
|
+
|
|
248
|
+
:raises Exception: re-raised on the final attempt when retries
|
|
249
|
+
exhausted, matching the process path's error propagation.
|
|
250
|
+
"""
|
|
251
|
+
# Only retry when ``safe_retry`` is True. ``validate_tasks`` has
|
|
252
|
+
# already asserted the chore is SINGLE_COMMIT in that case so
|
|
253
|
+
# whole-chore retry cannot leak partial state.
|
|
254
|
+
effective_retries = retries if task.safe_retry else 0
|
|
255
|
+
|
|
256
|
+
for attempt in range(effective_retries + 1):
|
|
257
|
+
try:
|
|
258
|
+
tm1.chores.execute_chore(task.chore_name)
|
|
259
|
+
return True, "Completed", "", attempt
|
|
260
|
+
except Exception:
|
|
261
|
+
if attempt == effective_retries:
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
# Defensive — loop body always returns or raises.
|
|
265
|
+
return False, "", "", effective_retries
|
|
266
|
+
|
|
267
|
+
|
|
224
268
|
def execute_process_with_retries(tm1: TM1Service, task: Task, retries: int):
|
|
225
269
|
for attempt in range(retries + 1):
|
|
226
270
|
try:
|
|
@@ -296,12 +340,21 @@ def execute_task(
|
|
|
296
340
|
return False
|
|
297
341
|
|
|
298
342
|
if task.instance_name not in tm1_services:
|
|
343
|
+
# Reuse the process-flavoured "instance not in config" message —
|
|
344
|
+
# the chore name slots in as the task identity for context.
|
|
299
345
|
msg = MSG_PROCESS_FAIL_INSTANCE_NOT_IN_CONFIG_FILE.format(
|
|
300
|
-
process_name=task.process_name
|
|
346
|
+
process_name=task.process_name or task.chore_name,
|
|
347
|
+
instance_name=task.instance_name,
|
|
301
348
|
)
|
|
302
349
|
logger.error(msg)
|
|
303
350
|
return False
|
|
304
351
|
|
|
352
|
+
# Dispatch to chore branch for chore-kind tasks. Mutual exclusion
|
|
353
|
+
# guarantees that ``chore_name`` is set iff ``process_name`` is not,
|
|
354
|
+
# so the dispatch is unambiguous.
|
|
355
|
+
if getattr(task, "chore_name", None):
|
|
356
|
+
return _execute_chore_task(ctx, task, retries, tm1_services)
|
|
357
|
+
|
|
305
358
|
tm1 = tm1_services[task.instance_name]
|
|
306
359
|
# Execute it - include stage in log if present
|
|
307
360
|
stage_info = f" [stage: {task.stage}]" if task.stage else ""
|
|
@@ -469,13 +522,128 @@ def execute_task(
|
|
|
469
522
|
return False
|
|
470
523
|
|
|
471
524
|
|
|
525
|
+
def _execute_chore_task(
|
|
526
|
+
ctx: ExecutionContext,
|
|
527
|
+
task: Task,
|
|
528
|
+
retries: int,
|
|
529
|
+
tm1_services: Dict[str, TM1Service],
|
|
530
|
+
) -> bool:
|
|
531
|
+
"""Execute a chore-kind task.
|
|
532
|
+
|
|
533
|
+
Parallel to the chore branch of :func:`execute_task` for processes.
|
|
534
|
+
Logs success/failure, records stats, and threads the chore identity
|
|
535
|
+
into the structured execution logger via the new ``chore=`` parameter.
|
|
536
|
+
"""
|
|
537
|
+
tm1 = tm1_services[task.instance_name]
|
|
538
|
+
stage_info = f" [stage: {task.stage}]" if task.stage else ""
|
|
539
|
+
logger.info(
|
|
540
|
+
MSG_CHORE_EXECUTE.format(
|
|
541
|
+
chore_name=task.chore_name,
|
|
542
|
+
instance_name=task.instance_name,
|
|
543
|
+
)
|
|
544
|
+
+ stage_info
|
|
545
|
+
)
|
|
546
|
+
start_time = datetime.now()
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
success, status, _, attempts = execute_chore_with_retries(
|
|
550
|
+
tm1=tm1, task=task, retries=retries
|
|
551
|
+
)
|
|
552
|
+
end_time = datetime.now()
|
|
553
|
+
elapsed_time = end_time - start_time
|
|
554
|
+
|
|
555
|
+
if success:
|
|
556
|
+
logger.info(
|
|
557
|
+
MSG_CHORE_SUCCESS.format(
|
|
558
|
+
chore=task.chore_name,
|
|
559
|
+
instance=task.instance_name,
|
|
560
|
+
retries=attempts,
|
|
561
|
+
time=elapsed_time,
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
if ctx.execution_logger:
|
|
566
|
+
ctx.execution_logger.log_task_execution(
|
|
567
|
+
task_id=task.id,
|
|
568
|
+
instance=task.instance_name,
|
|
569
|
+
process="",
|
|
570
|
+
chore=task.chore_name,
|
|
571
|
+
parameters={},
|
|
572
|
+
success=True,
|
|
573
|
+
start_time=start_time,
|
|
574
|
+
end_time=end_time,
|
|
575
|
+
retry_count=attempts,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
_collect_task_stats(
|
|
579
|
+
ctx,
|
|
580
|
+
task,
|
|
581
|
+
success=True,
|
|
582
|
+
start_time=start_time,
|
|
583
|
+
end_time=end_time,
|
|
584
|
+
retry_count=attempts,
|
|
585
|
+
)
|
|
586
|
+
return True
|
|
587
|
+
|
|
588
|
+
# Unreachable in practice: chore execution returns True or raises.
|
|
589
|
+
# Guarded for symmetry with the process path.
|
|
590
|
+
return False
|
|
591
|
+
|
|
592
|
+
except Exception as e:
|
|
593
|
+
end_time = datetime.now()
|
|
594
|
+
elapsed_time = end_time - start_time
|
|
595
|
+
# ``attempts`` is the global ``retries`` ceiling when safe_retry
|
|
596
|
+
# is enabled and we exhausted the loop, else 0.
|
|
597
|
+
attempts = retries if task.safe_retry else 0
|
|
598
|
+
error = str(e)
|
|
599
|
+
logger.error(
|
|
600
|
+
MSG_CHORE_FAIL.format(
|
|
601
|
+
chore=task.chore_name,
|
|
602
|
+
instance=task.instance_name,
|
|
603
|
+
retries=attempts,
|
|
604
|
+
time=elapsed_time,
|
|
605
|
+
error=error,
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if ctx.execution_logger:
|
|
610
|
+
ctx.execution_logger.log_task_execution(
|
|
611
|
+
task_id=task.id,
|
|
612
|
+
instance=task.instance_name,
|
|
613
|
+
process="",
|
|
614
|
+
chore=task.chore_name,
|
|
615
|
+
parameters={},
|
|
616
|
+
success=False,
|
|
617
|
+
start_time=start_time,
|
|
618
|
+
end_time=end_time,
|
|
619
|
+
retry_count=attempts,
|
|
620
|
+
error_message=error,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
_collect_task_stats(
|
|
624
|
+
ctx,
|
|
625
|
+
task,
|
|
626
|
+
success=False,
|
|
627
|
+
start_time=start_time,
|
|
628
|
+
end_time=end_time,
|
|
629
|
+
retry_count=attempts,
|
|
630
|
+
error_message=error,
|
|
631
|
+
)
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
|
|
472
635
|
def verify_predecessors_ok(ctx: ExecutionContext, task: OptimizedTask) -> bool:
|
|
636
|
+
# Use the kind-appropriate identity for log messages — chore tasks
|
|
637
|
+
# have no process name and no parameters to render.
|
|
638
|
+
target = task.process_name or task.chore_name
|
|
639
|
+
parameters = task.parameters if task.process_name else {}
|
|
640
|
+
|
|
473
641
|
for predecessor_id in task.predecessors:
|
|
474
642
|
if predecessor_id not in ctx.task_execution_results:
|
|
475
643
|
msg = MSG_PROCESS_ABORTED_UNCOMPLETE_PREDECESSOR.format(
|
|
476
644
|
instance=task.instance_name,
|
|
477
|
-
process=
|
|
478
|
-
parameters=
|
|
645
|
+
process=target,
|
|
646
|
+
parameters=parameters,
|
|
479
647
|
predecessor=predecessor_id,
|
|
480
648
|
)
|
|
481
649
|
logger.error(msg)
|
|
@@ -484,8 +652,8 @@ def verify_predecessors_ok(ctx: ExecutionContext, task: OptimizedTask) -> bool:
|
|
|
484
652
|
if not ctx.task_execution_results[predecessor_id]:
|
|
485
653
|
msg = MSG_PROCESS_ABORTED_FAILED_PREDECESSOR.format(
|
|
486
654
|
instance=task.instance_name,
|
|
487
|
-
process=
|
|
488
|
-
parameters=
|
|
655
|
+
process=target,
|
|
656
|
+
parameters=parameters,
|
|
489
657
|
predecessor=predecessor_id,
|
|
490
658
|
)
|
|
491
659
|
logger.error(msg)
|
|
@@ -495,21 +663,61 @@ def verify_predecessors_ok(ctx: ExecutionContext, task: OptimizedTask) -> bool:
|
|
|
495
663
|
|
|
496
664
|
|
|
497
665
|
def validate_tasks(tasks: List[Task], tm1_services: Dict[str, TM1Service]) -> bool:
|
|
498
|
-
|
|
666
|
+
validated_processes: List[str] = []
|
|
667
|
+
# Chore validation is keyed by (instance, chore_name) — chore names
|
|
668
|
+
# are scoped to a server and existence must be checked per-instance.
|
|
669
|
+
# safe_retry tasks additionally trigger a SINGLE_COMMIT check.
|
|
670
|
+
validated_chores: set = set()
|
|
671
|
+
safe_retry_chore_checked: set = set()
|
|
499
672
|
validation_ok = True
|
|
500
673
|
|
|
501
674
|
tasks = [task for task in tasks if isinstance(task, Task)] # --> ignore Wait(s)
|
|
502
675
|
for task in tasks:
|
|
676
|
+
tm1 = tm1_services[task.instance_name]
|
|
677
|
+
|
|
678
|
+
if getattr(task, "chore_name", None):
|
|
679
|
+
chore_key = (task.instance_name, task.chore_name)
|
|
680
|
+
|
|
681
|
+
# Existence check — dedup'd by (instance, chore_name).
|
|
682
|
+
if chore_key not in validated_chores:
|
|
683
|
+
validated_chores.add(chore_key)
|
|
684
|
+
if not tm1.chores.exists(task.chore_name):
|
|
685
|
+
logger.error(
|
|
686
|
+
MSG_CHORE_NOT_EXISTS.format(
|
|
687
|
+
chore=task.chore_name, instance=task.instance_name
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
validation_ok = False
|
|
691
|
+
# Skip the safe_retry check — chore doesn't exist.
|
|
692
|
+
continue
|
|
693
|
+
|
|
694
|
+
# SINGLE_COMMIT check — only fetched when safe_retry is True
|
|
695
|
+
# so we avoid the extra round-trip otherwise. Dedup'd
|
|
696
|
+
# independently so a mixed taskfile (some safe, some not)
|
|
697
|
+
# still triggers the fetch exactly once per chore.
|
|
698
|
+
if task.safe_retry and chore_key not in safe_retry_chore_checked:
|
|
699
|
+
safe_retry_chore_checked.add(chore_key)
|
|
700
|
+
chore = tm1.chores.get(task.chore_name)
|
|
701
|
+
if chore.execution_mode != Chore.SINGLE_COMMIT:
|
|
702
|
+
logger.error(
|
|
703
|
+
MSG_CHORE_REQUIRES_SINGLE_COMMIT.format(
|
|
704
|
+
chore=task.chore_name,
|
|
705
|
+
instance=task.instance_name,
|
|
706
|
+
execution_mode=chore.execution_mode,
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
validation_ok = False
|
|
710
|
+
continue
|
|
711
|
+
|
|
712
|
+
# Process branch (unchanged behaviour, slightly tidied).
|
|
503
713
|
current_task = {
|
|
504
714
|
"instance": task.instance_name,
|
|
505
715
|
"process": task.process_name,
|
|
506
716
|
"parameters": task.parameters.keys(),
|
|
507
717
|
}
|
|
508
718
|
|
|
509
|
-
tm1 = tm1_services[task.instance_name]
|
|
510
|
-
|
|
511
719
|
# avoid repeated validations
|
|
512
|
-
if current_task["process"] in
|
|
720
|
+
if current_task["process"] in validated_processes:
|
|
513
721
|
continue
|
|
514
722
|
|
|
515
723
|
# check for process existence
|
|
@@ -518,7 +726,7 @@ def validate_tasks(tasks: List[Task], tm1_services: Dict[str, TM1Service]) -> bo
|
|
|
518
726
|
process=task.process_name, instance=task.instance_name
|
|
519
727
|
)
|
|
520
728
|
logger.error(msg)
|
|
521
|
-
|
|
729
|
+
validated_processes.append(current_task["process"])
|
|
522
730
|
validation_ok = False
|
|
523
731
|
continue
|
|
524
732
|
|
|
@@ -540,7 +748,7 @@ def validate_tasks(tasks: List[Task], tm1_services: Dict[str, TM1Service]) -> bo
|
|
|
540
748
|
logger.error(msg)
|
|
541
749
|
validation_ok = False
|
|
542
750
|
|
|
543
|
-
|
|
751
|
+
validated_processes.append(current_task["process"])
|
|
544
752
|
|
|
545
753
|
return validation_ok
|
|
546
754
|
|