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.
Files changed (63) hide show
  1. {rushti-2.2.3/src/rushti.egg-info → rushti-2.3.0}/PKG-INFO +1 -1
  2. {rushti-2.2.3 → rushti-2.3.0}/pyproject.toml +1 -1
  3. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/__init__.py +1 -1
  4. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/cli.py +5 -2
  5. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/dashboard.py +61 -17
  6. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/execution.py +220 -12
  7. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/logging.py +17 -6
  8. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/messages.py +25 -0
  9. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/optimizer.py +4 -2
  10. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/parsing.py +19 -8
  11. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/dynamodb.py +9 -2
  12. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/repository.py +1 -0
  13. rushti-2.3.0/src/rushti/stats/signature.py +42 -0
  14. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/sqlite.py +29 -11
  15. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/task.py +66 -14
  16. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/taskfile.py +87 -10
  17. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/taskfile_ops.py +21 -9
  18. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/templates/visualization.html +20 -5
  19. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/tm1_integration.py +26 -7
  20. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/tm1_objects.py +10 -0
  21. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/visualization_template.py +20 -5
  22. {rushti-2.2.3 → rushti-2.3.0/src/rushti.egg-info}/PKG-INFO +1 -1
  23. rushti-2.2.3/src/rushti/stats/signature.py +0 -29
  24. {rushti-2.2.3 → rushti-2.3.0}/LICENSE +0 -0
  25. {rushti-2.2.3 → rushti-2.3.0}/README.md +0 -0
  26. {rushti-2.2.3 → rushti-2.3.0}/setup.cfg +0 -0
  27. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/_shlex_utils.py +0 -0
  28. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/app_paths.py +0 -0
  29. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/checkpoint.py +0 -0
  30. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/__init__.py +0 -0
  31. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/build.py +0 -0
  32. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/db.py +0 -0
  33. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/resume.py +0 -0
  34. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/__init__.py +0 -0
  35. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/analyze.py +0 -0
  36. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/export.py +0 -0
  37. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/list.py +0 -0
  38. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/optimize.py +0 -0
  39. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/stats/visualize.py +0 -0
  40. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/__init__.py +0 -0
  41. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/expand.py +0 -0
  42. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/export.py +0 -0
  43. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/push.py +0 -0
  44. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/validate.py +0 -0
  45. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/commands/tasks/visualize.py +0 -0
  46. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/contention_analyzer.py +0 -0
  47. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/dag.py +0 -0
  48. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/db_admin.py +0 -0
  49. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/exclusive.py +0 -0
  50. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/logging_setup.py +0 -0
  51. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/optimization_report.py +0 -0
  52. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/results_writer.py +0 -0
  53. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/settings.py +0 -0
  54. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/__init__.py +0 -0
  55. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/stats/paths.py +0 -0
  56. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/templates/__init__.py +0 -0
  57. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/tm1_build.py +0 -0
  58. {rushti-2.2.3 → rushti-2.3.0}/src/rushti/utils.py +0 -0
  59. {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/SOURCES.txt +0 -0
  60. {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/dependency_links.txt +0 -0
  61. {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/entry_points.txt +0 -0
  62. {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/requires.txt +0 -0
  63. {rushti-2.2.3 → rushti-2.3.0}/src/rushti.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rushti
3
- Version: 2.2.3
3
+ Version: 2.3.0
4
4
  Summary: RushTI - Parallel TM1 TurboIntegrator Process Execution
5
5
  Author-email: nicolasbisurgi <nbisurgi@cubewise.com>, Marius Wirtz <mwirtz@cubewise.com>
6
6
  Maintainer: Cubewise CODE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rushti"
7
- version = "2.2.3"
7
+ version = "2.3.0"
8
8
  description = "RushTI - Parallel TM1 TurboIntegrator Process Execution"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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.2.3"
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 format is auto-detected (--mode is deprecated for file sources).
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="[Deprecated] Execution mode is now auto-detected from file content. This option is kept for backwards compatibility but ignored.",
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": tr["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": tr["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": tr["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": tr["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)">Process <span class="sort-arrow"></span></th>
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>Process</th><th>Instance</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>Process</th><th>Instance</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, process: tr.process,
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', 'process', 'avg', 'mn', 'mx', 'stdDev', 'successRate', 'total'];
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>${{r.process}}</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>${{o.process}}</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 => `<tr>
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>${{f.process}}</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, '&quot;')}}">${{f.error_message || '-'}}</td>
1624
- </tr>`).join('');
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, instance_name=task.instance_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=task.process_name,
478
- parameters=task.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=task.process_name,
488
- parameters=task.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
- validated_tasks = []
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 validated_tasks:
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
- validated_tasks.append(current_task["process"])
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
- validated_tasks.append(current_task["process"])
751
+ validated_processes.append(current_task["process"])
544
752
 
545
753
  return validation_ok
546
754