rushti 2.2.2__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.2/src/rushti.egg-info → rushti-2.3.0}/PKG-INFO +1 -1
- {rushti-2.2.2 → rushti-2.3.0}/pyproject.toml +1 -1
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/__init__.py +1 -1
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/cli.py +5 -2
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/dashboard.py +61 -17
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/execution.py +229 -17
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/logging.py +17 -6
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/messages.py +25 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/optimizer.py +4 -2
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/parsing.py +19 -8
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/stats/dynamodb.py +9 -2
- {rushti-2.2.2 → 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.2 → rushti-2.3.0}/src/rushti/stats/sqlite.py +29 -11
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/task.py +66 -14
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/taskfile.py +87 -10
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/taskfile_ops.py +21 -9
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/templates/visualization.html +20 -5
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/tm1_integration.py +26 -7
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/tm1_objects.py +10 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/visualization_template.py +20 -5
- {rushti-2.2.2 → rushti-2.3.0/src/rushti.egg-info}/PKG-INFO +1 -1
- rushti-2.2.2/src/rushti/stats/signature.py +0 -29
- {rushti-2.2.2 → rushti-2.3.0}/LICENSE +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/README.md +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/setup.cfg +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/_shlex_utils.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/app_paths.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/checkpoint.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/__init__.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/build.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/db.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/resume.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/stats/__init__.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/stats/analyze.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/stats/export.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/stats/list.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/stats/optimize.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/stats/visualize.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/tasks/__init__.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/tasks/expand.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/tasks/export.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/tasks/push.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/tasks/validate.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/commands/tasks/visualize.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/contention_analyzer.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/dag.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/db_admin.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/exclusive.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/logging_setup.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/optimization_report.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/results_writer.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/settings.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/stats/__init__.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/stats/paths.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/templates/__init__.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/tm1_build.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti/utils.py +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti.egg-info/SOURCES.txt +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti.egg-info/dependency_links.txt +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti.egg-info/entry_points.txt +0 -0
- {rushti-2.2.2 → rushti-2.3.0}/src/rushti.egg-info/requires.txt +0 -0
- {rushti-2.2.2 → 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,
|
|
@@ -195,11 +206,15 @@ def setup_tm1_services(
|
|
|
195
206
|
# case no connection file provided or connection file expired
|
|
196
207
|
if tm1_server_name not in tm1_services:
|
|
197
208
|
resolved_params = resolve_tm1_params(config, tm1_server_name)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
# RushTI-managed defaults; any value explicitly set in
|
|
210
|
+
# config.ini takes precedence (avoids "multiple values for
|
|
211
|
+
# keyword argument" when e.g. connection_pool_size is set).
|
|
212
|
+
service_kwargs = {
|
|
213
|
+
"session_context": session_context,
|
|
214
|
+
"connection_pool_size": max_workers,
|
|
215
|
+
}
|
|
216
|
+
service_kwargs.update(resolved_params)
|
|
217
|
+
tm1_services[tm1_server_name] = TM1Service(**service_kwargs)
|
|
203
218
|
|
|
204
219
|
if connection_file:
|
|
205
220
|
# implicitly re-connects if session is timed out
|
|
@@ -217,6 +232,39 @@ def setup_tm1_services(
|
|
|
217
232
|
return tm1_services, tm1_preserve_connections
|
|
218
233
|
|
|
219
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
|
+
|
|
220
268
|
def execute_process_with_retries(tm1: TM1Service, task: Task, retries: int):
|
|
221
269
|
for attempt in range(retries + 1):
|
|
222
270
|
try:
|
|
@@ -292,12 +340,21 @@ def execute_task(
|
|
|
292
340
|
return False
|
|
293
341
|
|
|
294
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.
|
|
295
345
|
msg = MSG_PROCESS_FAIL_INSTANCE_NOT_IN_CONFIG_FILE.format(
|
|
296
|
-
process_name=task.process_name
|
|
346
|
+
process_name=task.process_name or task.chore_name,
|
|
347
|
+
instance_name=task.instance_name,
|
|
297
348
|
)
|
|
298
349
|
logger.error(msg)
|
|
299
350
|
return False
|
|
300
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
|
+
|
|
301
358
|
tm1 = tm1_services[task.instance_name]
|
|
302
359
|
# Execute it - include stage in log if present
|
|
303
360
|
stage_info = f" [stage: {task.stage}]" if task.stage else ""
|
|
@@ -465,13 +522,128 @@ def execute_task(
|
|
|
465
522
|
return False
|
|
466
523
|
|
|
467
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
|
+
|
|
468
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
|
+
|
|
469
641
|
for predecessor_id in task.predecessors:
|
|
470
642
|
if predecessor_id not in ctx.task_execution_results:
|
|
471
643
|
msg = MSG_PROCESS_ABORTED_UNCOMPLETE_PREDECESSOR.format(
|
|
472
644
|
instance=task.instance_name,
|
|
473
|
-
process=
|
|
474
|
-
parameters=
|
|
645
|
+
process=target,
|
|
646
|
+
parameters=parameters,
|
|
475
647
|
predecessor=predecessor_id,
|
|
476
648
|
)
|
|
477
649
|
logger.error(msg)
|
|
@@ -480,8 +652,8 @@ def verify_predecessors_ok(ctx: ExecutionContext, task: OptimizedTask) -> bool:
|
|
|
480
652
|
if not ctx.task_execution_results[predecessor_id]:
|
|
481
653
|
msg = MSG_PROCESS_ABORTED_FAILED_PREDECESSOR.format(
|
|
482
654
|
instance=task.instance_name,
|
|
483
|
-
process=
|
|
484
|
-
parameters=
|
|
655
|
+
process=target,
|
|
656
|
+
parameters=parameters,
|
|
485
657
|
predecessor=predecessor_id,
|
|
486
658
|
)
|
|
487
659
|
logger.error(msg)
|
|
@@ -491,21 +663,61 @@ def verify_predecessors_ok(ctx: ExecutionContext, task: OptimizedTask) -> bool:
|
|
|
491
663
|
|
|
492
664
|
|
|
493
665
|
def validate_tasks(tasks: List[Task], tm1_services: Dict[str, TM1Service]) -> bool:
|
|
494
|
-
|
|
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()
|
|
495
672
|
validation_ok = True
|
|
496
673
|
|
|
497
674
|
tasks = [task for task in tasks if isinstance(task, Task)] # --> ignore Wait(s)
|
|
498
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).
|
|
499
713
|
current_task = {
|
|
500
714
|
"instance": task.instance_name,
|
|
501
715
|
"process": task.process_name,
|
|
502
716
|
"parameters": task.parameters.keys(),
|
|
503
717
|
}
|
|
504
718
|
|
|
505
|
-
tm1 = tm1_services[task.instance_name]
|
|
506
|
-
|
|
507
719
|
# avoid repeated validations
|
|
508
|
-
if current_task["process"] in
|
|
720
|
+
if current_task["process"] in validated_processes:
|
|
509
721
|
continue
|
|
510
722
|
|
|
511
723
|
# check for process existence
|
|
@@ -514,7 +726,7 @@ def validate_tasks(tasks: List[Task], tm1_services: Dict[str, TM1Service]) -> bo
|
|
|
514
726
|
process=task.process_name, instance=task.instance_name
|
|
515
727
|
)
|
|
516
728
|
logger.error(msg)
|
|
517
|
-
|
|
729
|
+
validated_processes.append(current_task["process"])
|
|
518
730
|
validation_ok = False
|
|
519
731
|
continue
|
|
520
732
|
|
|
@@ -536,7 +748,7 @@ def validate_tasks(tasks: List[Task], tm1_services: Dict[str, TM1Service]) -> bo
|
|
|
536
748
|
logger.error(msg)
|
|
537
749
|
validation_ok = False
|
|
538
750
|
|
|
539
|
-
|
|
751
|
+
validated_processes.append(current_task["process"])
|
|
540
752
|
|
|
541
753
|
return validation_ok
|
|
542
754
|
|