Skip to content

Commit 8c9bf96

Browse files
Merge pull request #136 from cubewise-code/135-early-session-release-close-tm1-sessions-when-instance-has-no-remaining-tasks
Add early session release for multi-instance workflows
2 parents e973f1f + b4567a9 commit 8c9bf96

7 files changed

Lines changed: 552 additions & 13 deletions

File tree

.github/workflows/tests.yml

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,25 @@
66
# Unit tests and linting run on every push and PR.
77
# Integration tests only run on PRs to master (expensive, ~7 min).
88
#
9-
# Required secrets for integration tests:
10-
# RUSHTI_TEST_TM1_CONFIG - Full content of a config.ini file with TM1 connection details.
11-
# The file format follows the standard RushTI config.ini format.
12-
# See tests/config.ini.template for the expected structure.
9+
# Required configuration for integration tests:
10+
#
11+
# Variable (non-sensitive connection details):
12+
# RUSHTI_TEST_TM1_CONFIG - config.ini content WITHOUT passwords.
13+
# Example:
14+
# [tm1srv01]
15+
# base_url = https://your-server/tm1/api/tm1
16+
# user = your_user
17+
# namespace = LDAP
18+
# ssl = true
19+
# verify = true
20+
# async_requests_mode = true
21+
#
22+
# Secret (sensitive credentials only):
23+
# RUSHTI_TEST_TM1_PASSWORD - The password for tm1srv01.
24+
#
25+
# Legacy (still supported):
26+
# Secret: RUSHTI_TEST_TM1_CONFIG - Full config.ini content including password.
27+
# If the variable + secret pair is configured, it takes precedence over the legacy secret.
1328

1429
name: Tests
1530

@@ -103,15 +118,25 @@ jobs:
103118
- name: Check TM1 configuration
104119
id: check-tm1
105120
run: |
106-
if [ -n "${{ secrets.RUSHTI_TEST_TM1_CONFIG }}" ]; then
121+
if [ -n "${{ vars.RUSHTI_TEST_TM1_CONFIG }}" ] && [ -n "${{ secrets.RUSHTI_TEST_TM1_PASSWORD }}" ]; then
122+
echo "tm1_configured=true" >> $GITHUB_OUTPUT
123+
echo "config_source=variable" >> $GITHUB_OUTPUT
124+
elif [ -n "${{ secrets.RUSHTI_TEST_TM1_CONFIG }}" ]; then
107125
echo "tm1_configured=true" >> $GITHUB_OUTPUT
126+
echo "config_source=legacy_secret" >> $GITHUB_OUTPUT
108127
else
109128
echo "tm1_configured=false" >> $GITHUB_OUTPUT
110-
echo "::warning::TM1 config secret not configured. Integration tests will be skipped."
129+
echo "::warning::TM1 config not configured. Integration tests will be skipped."
111130
fi
112131
113-
- name: Create TM1 config file
114-
if: steps.check-tm1.outputs.tm1_configured == 'true'
132+
- name: Create TM1 config file (from variable + secret)
133+
if: steps.check-tm1.outputs.config_source == 'variable'
134+
run: |
135+
echo "${{ vars.RUSHTI_TEST_TM1_CONFIG }}" > tests/config.ini
136+
echo "password = ${{ secrets.RUSHTI_TEST_TM1_PASSWORD }}" >> tests/config.ini
137+
138+
- name: Create TM1 config file (legacy - full secret)
139+
if: steps.check-tm1.outputs.config_source == 'legacy_secret'
115140
run: |
116141
echo "${{ secrets.RUSHTI_TEST_TM1_CONFIG }}" > tests/config.ini
117142
@@ -129,9 +154,15 @@ jobs:
129154
- name: Skip message
130155
if: steps.check-tm1.outputs.tm1_configured != 'true'
131156
run: |
132-
echo "Integration tests skipped - TM1 config secret not configured"
133-
echo "To enable, add the RUSHTI_TEST_TM1_CONFIG secret to your repository."
134-
echo "The secret should contain the full config.ini content with TM1 connection details."
157+
echo "Integration tests skipped - TM1 config not configured"
158+
echo ""
159+
echo "To enable (recommended):"
160+
echo " 1. Add variable RUSHTI_TEST_TM1_CONFIG with connection details (no password)"
161+
echo " 2. Add secret RUSHTI_TEST_TM1_PASSWORD with the TM1 password"
162+
echo ""
163+
echo "Legacy (still supported):"
164+
echo " Add secret RUSHTI_TEST_TM1_CONFIG with the full config.ini content"
165+
echo ""
135166
echo "See tests/config.ini.template for the expected format."
136167
137168
lint:

docs/features/exclusive-mode.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,41 @@ timeout = 1800 # Wait up to 30 minutes
181181

182182
---
183183

184+
## Early Session Release
185+
186+
When a workflow spans multiple TM1 instances, RushTI automatically releases sessions from instances that have no remaining tasks — without waiting for the entire workflow to finish. This is especially valuable in exclusive mode, where holding an idle session blocks other RushTI instances from accessing that server.
187+
188+
### Example
189+
190+
Consider a workflow with 100 tasks: 5 tasks on `tm1-finance` (10 seconds) and 95 tasks on `tm1-reporting` (30 minutes).
191+
192+
| Behavior | `tm1-finance` Locked For |
193+
|----------|------------------------|
194+
| **Without** early release | 30 min 10 sec (entire workflow) |
195+
| **With** early release | 10 sec (only while its tasks run) |
196+
197+
Once the 5 tasks on `tm1-finance` complete, RushTI logs out from that instance immediately, freeing it for other workflows. The session on `tm1-reporting` continues until its tasks finish.
198+
199+
### What You See in Logs
200+
201+
```
202+
Executing task 5/100: RunExtract on tm1-finance
203+
Early session release: logged out from tm1-finance (no remaining tasks)
204+
Executing task 6/100: TransformData on tm1-reporting
205+
...
206+
```
207+
208+
### How It Works
209+
210+
- After each task completes, RushTI checks if any pending or running tasks remain for each connected TM1 instance.
211+
- If an instance has zero remaining tasks, the session is closed immediately.
212+
- In exclusive mode (`--exclusive`), even preserved connections (via `connection_file`) are released early.
213+
- In normal mode, preserved connections are kept open for reuse across runs.
214+
215+
This feature is always active — no configuration needed.
216+
217+
---
218+
184219
## When to Use Exclusive Mode
185220

186221
!!! tip "Use For"

src/rushti/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,8 @@ def main() -> int:
12401240
checkpoint_manager=checkpoint_manager,
12411241
task_optimizer=task_optimizer,
12421242
stage_workers=stage_workers,
1243+
tm1_preserve_connections=preserve_connections,
1244+
force_logout=exclusive_mode,
12431245
)
12441246
)
12451247
success = True

src/rushti/dag.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,23 @@ def get_execution_results(self) -> Dict[str, bool]:
303303
"""
304304
return dict(self._results)
305305

306+
def get_remaining_tasks_by_instance(self) -> Dict[str, int]:
307+
"""Get count of remaining (non-completed) tasks per TM1 instance.
308+
309+
A task is considered remaining if its status is PENDING or RUNNING.
310+
311+
:return: Dictionary mapping instance_name to count of remaining tasks
312+
"""
313+
counts: Dict[str, int] = {}
314+
for task_id, status in self._status.items():
315+
if status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.SKIPPED):
316+
continue
317+
for task in self._tasks.get(task_id, []):
318+
instance = getattr(task, "instance_name", None)
319+
if instance:
320+
counts[instance] = counts.get(instance, 0) + 1
321+
return counts
322+
306323
def __len__(self) -> int:
307324
"""Return the number of unique task IDs in the DAG."""
308325
return len(self._tasks)

src/rushti/execution.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,8 @@ async def work_through_tasks_dag(
550550
checkpoint_manager: "CheckpointManager" = None,
551551
task_optimizer: "TaskOptimizer" = None,
552552
stage_workers: Optional[Dict[str, int]] = None,
553+
tm1_preserve_connections: Optional[Dict] = None,
554+
force_logout: bool = False,
553555
) -> List[bool]:
554556
"""Execute tasks using DAG-based scheduling.
555557
@@ -563,6 +565,11 @@ async def work_through_tasks_dag(
563565
in addition to the global max_workers ceiling. The global max_workers
564566
always takes precedence as the absolute cap.
565567
568+
When tm1_preserve_connections is provided, early session release is enabled:
569+
after each task completion, instances with no remaining tasks are logged out
570+
immediately to free TM1 server resources and release exclusive locks.
571+
Instances marked as preserved are exempt unless force_logout is True.
572+
566573
:param ctx: The current execution context
567574
:param dag: DAG containing tasks and their dependencies
568575
:param max_workers: Maximum number of concurrent workers
@@ -571,11 +578,17 @@ async def work_through_tasks_dag(
571578
:param checkpoint_manager: Optional CheckpointManager for resume support
572579
:param task_optimizer: Optional TaskOptimizer for runtime-based scheduling
573580
:param stage_workers: Optional per-stage worker limits (e.g. {"extract": 8, "load": 4})
581+
:param tm1_preserve_connections: Optional dict indicating which connections to preserve.
582+
When provided, enables early session release. Preserved connections are exempt
583+
from early release unless force_logout is True.
584+
:param force_logout: If True, force logout even from preserved connections (exclusive mode)
574585
:return: List of execution outcomes (True/False for each task)
575586
"""
576587
outcomes = []
577588
loop = asyncio.get_event_loop()
578589
task_start_times: Dict[str, datetime] = {}
590+
# Track instances that have been early-released to avoid double-logout
591+
released_instances: set = set()
579592

580593
with ThreadPoolExecutor(int(max_workers)) as executor:
581594
# Map futures to tasks
@@ -672,6 +685,23 @@ def submit_ready_tasks():
672685
error_message=None if success else "Task failed",
673686
)
674687

688+
# Early session release: logout from instances with no remaining tasks.
689+
# Only active when tm1_preserve_connections is provided (i.e. called from CLI).
690+
# Direct callers (e.g. integration tests) that don't pass it won't trigger
691+
# early release, preserving shared connection state across test methods.
692+
if tm1_preserve_connections is not None:
693+
remaining = dag.get_remaining_tasks_by_instance()
694+
for instance_name in list(tm1_services.keys()):
695+
if instance_name not in remaining and instance_name not in released_instances:
696+
released = _logout_instance(
697+
instance_name,
698+
tm1_services,
699+
tm1_preserve_connections,
700+
force_logout,
701+
)
702+
if released:
703+
released_instances.add(instance_name)
704+
675705
# Submit newly ready tasks
676706
submit_ready_tasks()
677707

@@ -682,6 +712,40 @@ def submit_ready_tasks():
682712
return outcomes
683713

684714

715+
def _logout_instance(
716+
instance_name: str,
717+
tm1_services: Dict,
718+
tm1_preserve_connections: Dict,
719+
force: bool = False,
720+
):
721+
"""Logout from a single TM1 instance.
722+
723+
Used for early session release when an instance has no remaining tasks.
724+
Does NOT remove the instance from tm1_services — the caller tracks
725+
which instances have been released to avoid double-release.
726+
727+
:param instance_name: Name of the TM1 instance to logout from
728+
:param tm1_services: Dictionary of TM1Service instances
729+
:param tm1_preserve_connections: Dictionary indicating which connections to preserve
730+
:param force: If True, logout even from preserved connections
731+
:return: True if logout was performed, False if skipped
732+
"""
733+
if instance_name not in tm1_services:
734+
return False
735+
736+
if not force and tm1_preserve_connections.get(instance_name, False):
737+
logger.debug(f"Preserving connection to {instance_name} (early release skipped)")
738+
return False
739+
740+
try:
741+
tm1_services[instance_name].logout()
742+
logger.info(f"Early session release: logged out from {instance_name} (no remaining tasks)")
743+
return True
744+
except Exception as e:
745+
logger.warning(f"Failed early logout from {instance_name}: {e}")
746+
return False
747+
748+
685749
def logout(
686750
tm1_services: Dict,
687751
tm1_preserve_connections: Dict,

tests/config.ini.template

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,15 @@
1010
# 1. RUSHTI_TEST_CONFIG environment variable (path to a config.ini file)
1111
# 2. tests/config.ini (this file, when copied and filled in)
1212
#
13-
# For CI/CD (GitHub Actions), set the RUSHTI_TEST_TM1_CONFIG secret to the
14-
# full content of a config.ini file. The CI workflow writes it to tests/config.ini.
13+
# For CI/CD (GitHub Actions), configure either:
14+
#
15+
# Recommended: Variable + Secret (connection details visible, password protected)
16+
# - Variable RUSHTI_TEST_TM1_CONFIG: config.ini content WITHOUT password
17+
# - Secret RUSHTI_TEST_TM1_PASSWORD: the password value only
18+
# The CI workflow merges them into tests/config.ini at runtime.
19+
#
20+
# Legacy: Single secret (entire config opaque)
21+
# - Secret RUSHTI_TEST_TM1_CONFIG: full config.ini content including password
1522
#
1623
# Multi-Instance Testing:
1724
# You can define multiple TM1 instances. Tests that require a specific version

0 commit comments

Comments
 (0)