Skip to content

Commit 8d07bfa

Browse files
Merge pull request #150 from cubewise-code/feat/issue-146-detailed-results
Add --detailed-results for per-execution cube rows (closes #146)
2 parents 330403d + 7fca0ce commit 8d07bfa

36 files changed

Lines changed: 2663 additions & 942 deletions

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Changelog
2+
3+
All notable changes to RushTI are documented in this file.
4+
5+
## Unreleased — `feat/issue-146-detailed-results`
6+
7+
- Add `--detailed-results` for per-execution cube rows (closes #146).
8+
- Log migration hint at run start when `--detailed-results` is enabled.
9+
- Fix: `--tm1-instance` CLI argument now overrides `default_tm1_instance` for
10+
results push and auto-load, not just for taskfile read.
11+
- Fix: parameter parser no longer treats backslash as an escape character
12+
inside quoted values; Windows-style paths (e.g. `F:\Cons\Go_Files\`) parse
13+
correctly in both TM1-sourced and text-based taskfiles.
14+
- Change: parameters in pushed result rows are now rendered as inline
15+
`key="value"` pairs (matching the input cube format) instead of a JSON
16+
dictionary.

docs/advanced/cli-reference.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ rushti --tasks FILE [options] # 'run' is the default command
6060
| `--force` | `-f` | FLAG | `false` | Bypass exclusive mode checks and run immediately. |
6161
| `--optimize` | | CHOICE | *(none)* | Enable task optimization with a scheduling algorithm: `longest_first` or `shortest_first`. |
6262
| `--no-checkpoint` | | FLAG | `false` | Disable checkpoint saving for this run. |
63+
| `--detailed-results` | | FLAG | `false` | When pushing results to TM1, emit one row per executed TI instead of summarizing expanded tasks. Each row gets a fresh sequential `task_id`; the original `task_id` is preserved in the new `original_task_id` measure. See the [TM1 integration docs](../features/tm1-integration.md#detailed-results) for the join contract. |
6364
| `--tm1-instance` | | STR | *(none)* | Read task file from TM1 instead of disk. Requires `--workflow`. |
6465
| `--workflow` | `-W` | STR | *(none)* | Workflow identifier. Defaults to JSON metadata `workflow` field or the taskfile filename stem if omitted. Required when using `--tm1-instance`. |
6566
| `--log-level` | `-L` | CHOICE | `INFO` | Override log level for this run. |
@@ -284,7 +285,7 @@ rushti stats export --workflow daily-etl --run-id 20260115_103000 --output run.c
284285

285286
### rushti stats visualize
286287

287-
Generate an interactive HTML dashboard with Gantt charts, success rates, and execution trends. Automatically opens the dashboard in the default browser.
288+
Generate an interactive HTML dashboard with Gantt charts, success rates, and execution trends, plus a companion DAG visualization rebuilt from the latest run's recorded predecessors. Automatically opens the dashboard in the default browser.
288289

289290
```bash
290291
rushti stats visualize --workflow daily-etl
@@ -298,6 +299,11 @@ rushti stats visualize --workflow daily-etl --runs 10 --output dashboard.html
298299
| `--output` | `-o` | PATH | Output HTML file path (default: `visualizations/rushti_dashboard_<id>.html`) |
299300
| `--settings` | `-s` | PATH | Path to `settings.ini` |
300301

302+
!!! info "DAG reflects the latest executed run"
303+
Both the dashboard and the DAG are sourced from the stats database — not from the live taskfile or TM1 cube definition. Editing a workflow definition without re-running it will not update either visualization. Re-run the workflow to refresh.
304+
305+
When a task expanded via MDX wildcards (one parent task fanned out to N executions), the DAG now renders **one node per executed TI**, suffixing shared `task_id` values (`2.1`, `2.2`, `2.3`). Predecessors fan out to all expansions of the parent. Earlier versions deduped by `task_id`, hiding fan-out and making the DAG appear unchanged across edits to expandable parameters.
306+
301307
---
302308

303309
### rushti stats analyze

docs/advanced/migration-guide.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,71 @@ id=4 instance=tm1srv01 process=Load.Cube pYear=2026 predecessors=3
105105

106106
---
107107

108+
## Numeric `task_id` Required {#numeric-task_id-required}
109+
110+
Starting with the version that introduces `--detailed-results`, the JSON taskfile schema rejects non-integer `task_id` values. RushTI was always intended to use numeric IDs — the `rushti_task_id` cube dimension is pre-populated with integer-named elements `"1".."5000"` and result rows can only land in the cube if their `task_id` matches an element. Earlier releases were lax about this; the validator now enforces it explicitly.
111+
112+
**Accepted forms:**
113+
114+
- JSON integer: `"id": 5`
115+
- Integer-shaped string: `"id": "5"`
116+
117+
**Rejected (now raises a `TaskfileValidationError` at parse time):**
118+
119+
| Bad value | Reason |
120+
|-----------|--------|
121+
| `"id": 0` | Zero is not a positive integer; cube dimension starts at 1 |
122+
| `"id": -3` | Negatives have no cube element |
123+
| `"id": "05"` | Leading-zero strings don't match the integer-named cube elements (`"5"` not `"05"`) |
124+
| `"id": 5.0` | Floats are not integers |
125+
| `"id": "task-1"` | Non-integer string |
126+
| `"id": "abc"` | Non-integer string |
127+
128+
The same rule applies to entries in `predecessors`.
129+
130+
### How to Migrate Legacy Taskfiles
131+
132+
If your taskfile uses string IDs like `"task-1"`, `"extract-load"`, or `"e1"`, rewrite them as integers (`1`, `2`, `3`, ...) and update every `predecessors` reference accordingly. The order doesn't matter — pick whatever sequence is convenient.
133+
134+
```json
135+
// Before
136+
{
137+
"tasks": [
138+
{"id": "extract-1", "instance": "tm1srv01", "process": "..." },
139+
{"id": "transform-1", "predecessors": ["extract-1"], "instance": "tm1srv01", "process": "..." }
140+
]
141+
}
142+
143+
// After
144+
{
145+
"tasks": [
146+
{"id": 1, "instance": "tm1srv01", "process": "..." },
147+
{"id": 2, "predecessors": [1], "instance": "tm1srv01", "process": "..." }
148+
]
149+
}
150+
```
151+
152+
The error message you'll see if validation fails:
153+
154+
```
155+
Task file validation failed:
156+
- Task 0: 'id' must be a positive integer. Got: 'extract-1'. Task IDs must be positive integers (the rushti_task_id cube dimension uses integer member names).
157+
```
158+
159+
---
160+
161+
## TM1 Model Auto-Upgrade
162+
163+
`rushti build` is now non-destructive by default. When you upgrade RushTI to a release that adds new measure elements (e.g. `original_task_id`), the next `rushti build --tm1-instance <inst>` patches the existing model in place:
164+
165+
- **Dimensions.** Missing measure elements are added; existing elements and their attribute values are left alone. Cube data is preserved.
166+
- **`}rushti.load.results`.** The TI process is application-owned and is always replaced with the latest body — TI processes are stateless, so this is safe.
167+
- **`--force`.** Still wipes and rebuilds dimensions and the cube (data loss). Use only on dev/test environments.
168+
169+
You don't need a separate command — bare `rushti build` does the right thing. CI/CD pipelines that run `rushti build` on every release continue to work across version upgrades without manual intervention.
170+
171+
---
172+
108173
## Step-by-Step Migration
109174

110175
### Phase 1: Install and Verify (Day 1)

docs/advanced/settings-reference.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Controls TM1-based read/write integration: reading task files from a TM1 cube an
119119
|---------|------|---------|-------------|
120120
| `push_results` | bool | `false` | Upload the results CSV to the TM1 Applications folder after each run. The file is named `rushti_{workflow}_{run_id}.csv` (with `.blb` extension for TM1 < v12). |
121121
| `auto_load_results` | bool | `false` | After uploading results (requires `push_results = true`), call the `}rushti.load.results` TI process on the target TM1 instance to load the CSV into the rushti cube. Passes `pSourceFile` and `pTargetCube` parameters. The process must exist on the target instance. |
122+
| `detailed_results` | bool | `false` | Emit one cube row per executed TI when pushing results, instead of summarizing expanded tasks (only meaningful with `push_results = true`). Each row gets a fresh sequential `task_id`; the original IDs are preserved in the new `original_task_id` measure. See [TM1 integration: detailed results](../features/tm1-integration.md#detailed-results). |
122123
| `default_tm1_instance` | str | *(none)* | Default TM1 instance name (from `config.ini`) used for reading task files and writing results. Required when `push_results` is enabled. |
123124
| `default_rushti_cube` | str | `rushti` | Name of the TM1 cube for task definitions and execution results. Created by the `rushti build` command. |
124125
| `default_workflow_dim` | str | `rushti_workflow` | Dimension name for workflow identifiers. |
@@ -128,7 +129,9 @@ Controls TM1-based read/write integration: reading task files from a TM1 cube an
128129

129130
**Overridable via CLI:** `--tm1-instance`, `--workflow` / `-W`
130131

131-
**Overridable via JSON task file:** `push_results`, `auto_load_results`
132+
**Overridable via CLI:** `--detailed-results`
133+
134+
**Overridable via JSON task file:** `push_results`, `auto_load_results`, `detailed_results`
132135

133136
!!! info "Setting Up TM1 Integration"
134137
Run `rushti build --tm1-instance <instance>` to create the required dimensions and cube automatically before enabling `push_results`.

docs/features/dag-execution.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ Open the HTML file in any browser to see an interactive graph with:
139139
- Click a node to see task details (instance, process, parameters)
140140
- Search and filter by task ID or process name
141141

142+
`rushti tasks visualize` shows the **definition** (one node per task in the file). For a post-execution view that includes MDX expansions as separate nodes, use `rushti stats visualize --workflow <id>` instead — that one rebuilds the DAG from the latest recorded run, with one node per executed TI.
143+
142144
### Validate Before Running
143145

144146
Check for structural problems without connecting to TM1:
@@ -184,3 +186,4 @@ Settings are resolved in priority order: CLI arguments > JSON task file settings
184186
- **[Advanced Task Files](../advanced/advanced-task-files.md)** — Stages, timeouts, expandable parameters
185187
- **[Task File Basics](../getting-started/task-files.md)** — Complete format reference (JSON and TXT)
186188
- **[Self-Optimization](optimization.md)** — Let RushTI learn from past runs and optimize task ordering
189+
- **[TM1 Integration: Detailed Results](tm1-integration.md#detailed-results)** — Per-execution rows in the cube for expanded tasks, joined via `original_task_id`

docs/features/tm1-integration.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,42 @@ ExecuteCommand(cmd, 1);
320320

321321
---
322322

323+
## Detailed Results
324+
325+
By default, when a workflow uses [expandable tasks](../advanced/advanced-task-files.md) (parameters with the `*` suffix), all expansions of one parent task share the same `task_id` and the cube load step collapses them into a single summary row per original `task_id`. This is the only viable behaviour with the cube's `task_id` dimension, but it hides per-execution detail (individual durations, which specific parameter combo failed, retry counts per expansion).
326+
327+
The `--detailed-results` flag (or `detailed_results = true` in `settings.ini` / the taskfile `settings` block) flips this: every executed TI gets its own row in the cube.
328+
329+
### What Changes in the Cube
330+
331+
- **One row per executed TI.** A workflow `[1, 2*(60), 3]` produces 62 rows instead of 3.
332+
- **Sequential gap-free `task_id`.** All rows are renumbered starting at 1 (`1..62`).
333+
- **`original_task_id` measure preserves identity.** Every row carries the pre-renumber ID, so the 60 expansions of `2*` all show `original_task_id = "2"`.
334+
- **`predecessors` references `original_task_id`.** Downstream tasks reconcile fan-in by joining `predecessors` against `original_task_id` in the same view — not against the new `task_id`.
335+
- **The stats DB is unchanged.** Results in the local SQLite/DynamoDB store remain keyed by the original `task_id`. The renumbering is purely a presentation step at upload time.
336+
337+
The new `original_task_id` measure is added to the `rushti_measure` dimension automatically the next time you run `rushti build` (the upgrade is non-destructive — existing cube data is preserved). The `}rushti.load.results` TI is also rewritten to populate the new column.
338+
339+
### Cross-Run Stability
340+
341+
`task_id` values under detailed-results are **not stable across runs** — if the MDX in an expandable task returns a different number of members between runs, every renumbered ID downstream shifts. This is by design. The stable identity that survives across runs is the workflow definition stored under the `Input` element of the `rushti_run_id` dimension. For cross-run analysis, join through that input snapshot, the `task_signature` field in the stats DB, or the `original_task_id` measure.
342+
343+
### Stage Grouping Recommendation
344+
345+
Assign a unique `stage` value per expandable task so the fan-out groups visually in the cube. Every expansion inherits the parent's stage and lands together in views.
346+
347+
### Dimension Sizing
348+
349+
The `rushti_task_id` dimension defaults to 5,000 members. A workflow whose detailed-results renumbering exceeds 5,000 will fail to load into the cube — extend the dimension before enabling detailed-results on very wide workflows. RushTI does not auto-grow the dimension at upload time and does not perform extra TM1 round-trips to detect this case.
350+
351+
### DAG Visualization Mirrors the Same Behavior
352+
353+
`rushti stats visualize` rebuilds the DAG from the latest run's recorded predecessors. When that run involved expansions, the DAG now renders **one node per executed TI**, suffixing shared `task_id` values (`2.1`, `2.2`, `2.3`) and fanning predecessor edges out to every expansion of the parent. This is the visual analog of `--detailed-results` in the cube: per-execution rows in the cube ↔ per-execution nodes in the DAG.
354+
355+
The stats database is the single source of truth for both the dashboard and the DAG. Editing the workflow definition (in the cube or in a JSON file) without re-running it will **not** refresh either visualization — re-run the workflow to update.
356+
357+
---
358+
323359
## Customize Further
324360

325361
- **[Statistics & Dashboards](statistics.md)** — The local SQLite database that feeds TM1 integration

docs/getting-started/task-files.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ TXT files are the simplest way to get started. Each line is a task, and you have
2929
id="4" predecessors="1,3" instance="tm1srv01" process="}bedrock.server.wait" pWaitSec=3
3030
```
3131

32-
!!! tip "Use integer IDs"
33-
Always use integers (`"1"`, `"2"`, `"3"`, ...) for task IDs. This keeps things simple and is required if you plan to use [expandable parameters](../advanced/advanced-task-files.md) with MDX expressions.
32+
!!! warning "Task IDs must be positive integers"
33+
`id` must be a positive integer (`1`, `2`, `3`, ...) — either a JSON integer (`"id": 1`) or an integer-shaped string (`"id": "1"`). Zero, negatives, leading-zero strings (`"05"`), floats, and non-numeric strings (`"task-1"`, `"abc"`) are rejected at parse time. Predecessors must follow the same rule.
34+
35+
The reason is concrete: the `rushti_task_id` cube dimension is pre-populated with integer-named elements `"1".."5000"`. Result rows can only land in the cube if their `task_id` matches an existing dimension element. See the [migration guide](../advanced/migration-guide.md) if you have legacy taskfiles using string IDs.
3436

3537
---
3638

src/rushti/_shlex_utils.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Shared shlex helpers for rushti parameter parsing.
2+
3+
TM1 process parameters never use ``\\`` as an escape character — it is always
4+
a literal path separator (e.g. ``F:\\Cons\\Go_Files\\``). The default
5+
``shlex.split(..., posix=True)`` treats backslash as an escape, which trips
6+
on Windows-style paths and drops the whole parameter set.
7+
8+
Implementation note: ``shlex.split(..., posix=False)`` preserves backslashes
9+
as literals but does *not* group whitespace-containing quoted strings
10+
(``"Working Forecast"`` would split on the inner space). To get both
11+
behaviors — literal backslashes AND quote-grouping — we pre-escape every
12+
``\\`` in the input so POSIX mode preserves it, then tokenize with POSIX
13+
semantics and strip the (already-consumed) outer quotes via the shlex
14+
parser itself.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import shlex
20+
from typing import List
21+
22+
23+
def shlex_split_literal_backslashes(text: str) -> List[str]:
24+
"""Tokenize ``text`` like :func:`shlex.split` but treat backslash literally.
25+
26+
Quote-grouping still applies: ``param="value with spaces"`` yields one
27+
token ``param=value with spaces``.
28+
29+
:param text: The raw parameter string.
30+
:return: List of tokens with backslashes preserved.
31+
:raises ValueError: If quotes are unbalanced.
32+
"""
33+
pre_escaped = text.replace("\\", "\\\\")
34+
return shlex.split(pre_escaped, posix=True)

src/rushti/cli.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,18 @@ def create_argument_parser() -> argparse.ArgumentParser:
337337
help="Disable checkpoint saving for this run",
338338
)
339339

340+
parser.add_argument(
341+
"--detailed-results",
342+
dest="detailed_results",
343+
action="store_true",
344+
default=None,
345+
help=(
346+
"Emit one row per executed TI in the cube results, instead of "
347+
"summarizing expansions. Each row gets a fresh sequential task_id "
348+
"and the original_task_id measure preserves the pre-renumber identity."
349+
),
350+
)
351+
340352
parser.add_argument(
341353
"--optimize",
342354
dest="optimize",
@@ -410,6 +422,7 @@ def parse_named_arguments(argv: list):
410422
"tm1_instance": tm1_instance,
411423
"workflow": workflow,
412424
"log_level": args.log_level,
425+
"detailed_results": args.detailed_results,
413426
}
414427

415428
return tasks_file_path, cli_args
@@ -619,6 +632,15 @@ def main() -> int:
619632
# Apply CLI overrides to settings
620633
settings = get_effective_settings(settings, cli_args=cli_args)
621634

635+
if settings.tm1_integration.detailed_results:
636+
logging.info(
637+
"Detailed results enabled: one cube row per executed TI. "
638+
"If you upgraded RushTI from an earlier release, run "
639+
"'rushti build --tm1-instance <instance>' once to add the "
640+
"'original_task_id' measure to the rushti_measure dimension. "
641+
"The build is non-destructive — existing cube data is preserved."
642+
)
643+
622644
# Extract final values (CLI overrides settings.ini)
623645
max_workers = (
624646
cli_args["max_workers"]
@@ -1092,12 +1114,17 @@ def main() -> int:
10921114
connect_to_tm1_instance,
10931115
build_results_dataframe,
10941116
summarize_expanded_tasks,
1117+
assign_unique_task_ids,
10951118
)
10961119

1097-
tm1_instance = settings.tm1_integration.default_tm1_instance
1098-
if tm1_instance:
1120+
# CLI --tm1-instance wins over default_tm1_instance for every
1121+
# TM1 operation in this run (taskfile read, results push,
1122+
# auto_load_results). Silent precedence — no extra warning
1123+
# when CLI overrides default.
1124+
upload_instance = tm1_instance or settings.tm1_integration.default_tm1_instance
1125+
if upload_instance:
10991126
tm1_upload = connect_to_tm1_instance(
1100-
tm1_instance,
1127+
upload_instance,
11011128
CONFIG,
11021129
)
11031130
try:
@@ -1106,7 +1133,10 @@ def main() -> int:
11061133
workflow,
11071134
ctx.execution_logger.run_id if ctx.execution_logger else "",
11081135
)
1109-
results_df = summarize_expanded_tasks(results_df)
1136+
if settings.tm1_integration.detailed_results:
1137+
results_df = assign_unique_task_ids(results_df)
1138+
else:
1139+
results_df = summarize_expanded_tasks(results_df)
11101140
if not results_df.empty:
11111141
file_name = upload_results_to_tm1(
11121142
tm1_upload,
@@ -1130,13 +1160,13 @@ def main() -> int:
11301160
)
11311161
logger.info(
11321162
"Executed }rushti.load.results on %s",
1133-
tm1_instance,
1163+
upload_instance,
11341164
)
11351165
if not success:
11361166
logger.warning(
11371167
"auto_load_results: Failed to execute "
11381168
"}rushti.load.results on %s: %s",
1139-
tm1_instance,
1169+
upload_instance,
11401170
status,
11411171
)
11421172
finally:

0 commit comments

Comments
 (0)