Skip to content

Commit c7d34d6

Browse files
wip, testing/fixes
1 parent 7a99bef commit c7d34d6

12 files changed

Lines changed: 175 additions & 34 deletions

.github/workflows/pull_request.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ jobs:
106106
integration-test:
107107
runs-on: ubuntu-latest
108108
timeout-minutes: 30
109+
strategy:
110+
fail-fast: false
111+
matrix:
112+
bucket: [test-all, long-sync, long-async, core]
113+
name: integration-test (${{ matrix.bucket }})
109114
env:
110115
CONDUCTOR_SERVER_URL: ${{ vars.SDKDEV_V5_SERVER_URL }}
111116
CONDUCTOR_AUTH_KEY: ${{ vars.SDKDEV_V5_AUTH_KEY }}
@@ -127,4 +132,4 @@ jobs:
127132
pip install pytest
128133
129134
- name: Run integration tests
130-
run: bash scripts/run_integration_tests.sh
135+
run: bash scripts/run_integration_tests.sh --bucket=${{ matrix.bucket }}

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,17 @@ line-ending = "auto"
192192
[tool.pytest.ini_options]
193193
pythonpath = ["src"]
194194
markers = [
195+
<<<<<<< HEAD
195196
"integration: requires a live Agentspan-compatible server",
196197
"e2e: requires a live server and AGENTSPAN_SERVER_URL",
197198
"sse: requires SSE streaming (AGENTSPAN_STREAMING_ENABLED=true)",
198199
"agent_correctness: marks tests as agent correctness tests",
199200
"semantic: marks tests that use an LLM judge for semantic assertions",
201+
=======
202+
"slow_sync: long-running sync lease-extension tests (~90s)",
203+
"slow_async: long-running async lease-extension tests (~90s)",
204+
"slow_test_all: long-running aggregate workflow-client test_all (~83s)",
205+
>>>>>>> 973c79cf (wip, testing/fixes)
200206
]
201207

202208
[tool.coverage.run]

scripts/run_integration_tests.sh

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@
2424
# ./scripts/run_integration_tests.sh
2525
# ./scripts/run_integration_tests.sh --with-perf # also run the perf test
2626
#
27+
# Buckets (--bucket=<name>): the slowest tests are split into their own buckets
28+
# so they can run as separate parallel CI jobs and are skipped by default
29+
# locally. Each slow bucket is path-scoped so it only imports its own module.
30+
# core (default) everything except the slow buckets below
31+
# long-sync sync lease-extension tests (test_lease_extension.py, ~90s)
32+
# long-async async lease-extension tests (test_async_lease_extension.py, ~90s)
33+
# test-all aggregate workflow-client test_all (test_workflow_client_intg.py, ~83s)
34+
# all the full suite (no bucket filtering) — for a complete local run
35+
#
36+
# ./scripts/run_integration_tests.sh # fast: skips slow buckets
37+
# ./scripts/run_integration_tests.sh --bucket=long-sync
38+
# ./scripts/run_integration_tests.sh --bucket=all # run everything
39+
#
2740
# Any other arguments are passed straight through to pytest, which is handy for
2841
# targeting a subset of tests or getting more detail on failures:
2942
#
@@ -48,22 +61,66 @@ set -euo pipefail
4861
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4962
cd "$REPO_ROOT"
5063

64+
# Slow-test files that get their own buckets / CI jobs (see --bucket above).
65+
LEASE_SYNC=tests/integration/test_lease_extension.py
66+
LEASE_ASYNC=tests/integration/test_async_lease_extension.py
67+
TEST_ALL_FILE=tests/integration/test_workflow_client_intg.py
68+
5169
# The perf test is skipped by default; --with-perf opts back in.
5270
perf_ignore=(--ignore=tests/integration/test_update_task_v2_perf.py)
71+
bucket="core"
5372
pytest_args=()
5473
for arg in "$@"; do
55-
if [[ "$arg" == "--with-perf" ]]; then
56-
perf_ignore=()
57-
else
58-
pytest_args+=("$arg")
59-
fi
74+
case "$arg" in
75+
--with-perf) perf_ignore=() ;;
76+
--bucket=*) bucket="${arg#*=}" ;;
77+
*) pytest_args+=("$arg") ;;
78+
esac
6079
done
6180

81+
# The AI/agentic tests always target a dedicated server (see header) and are
82+
# never part of these buckets.
83+
ai_ignore=(
84+
--ignore=tests/integration/test_ai_task_types.py
85+
--ignore=tests/integration/test_ai_examples.py
86+
--ignore=tests/integration/test_agentic_workflows.py
87+
)
88+
89+
# Build the target paths + selection for the chosen bucket. The slow buckets are
90+
# path-scoped so each job only imports its own module: this keeps
91+
# scan_for_annotated_workers from starting unrelated workers and lets the four
92+
# buckets run in parallel against one server without stealing each other's tasks.
93+
case "$bucket" in
94+
core)
95+
targets=(tests/integration)
96+
select=("${ai_ignore[@]}" ${perf_ignore[@]+"${perf_ignore[@]}"} \
97+
--ignore="$LEASE_SYNC" --ignore="$LEASE_ASYNC" --ignore="$TEST_ALL_FILE")
98+
;;
99+
all)
100+
targets=(tests/integration)
101+
select=("${ai_ignore[@]}" ${perf_ignore[@]+"${perf_ignore[@]}"})
102+
;;
103+
long-sync)
104+
targets=("$LEASE_SYNC")
105+
select=(-m slow_sync)
106+
;;
107+
long-async)
108+
targets=("$LEASE_ASYNC")
109+
select=(-m slow_async)
110+
;;
111+
test-all)
112+
targets=("$TEST_ALL_FILE")
113+
select=(-m slow_test_all)
114+
;;
115+
*)
116+
echo "Unknown --bucket='$bucket' (expected: core, long-sync, long-async, test-all, all)" >&2
117+
exit 2
118+
;;
119+
esac
120+
62121
# Note: the "${arr[@]+"${arr[@]}"}" form is required so empty arrays don't trip
63122
# "unbound variable" under `set -u` on bash 3.2 (the default macOS bash).
64-
exec python3 -m pytest tests/integration -v \
65-
--ignore=tests/integration/test_ai_task_types.py \
66-
--ignore=tests/integration/test_ai_examples.py \
67-
--ignore=tests/integration/test_agentic_workflows.py \
68-
${perf_ignore[@]+"${perf_ignore[@]}"} \
123+
exec python3 -m pytest -v \
124+
"${targets[@]}" \
125+
${select[@]+"${select[@]}"} \
69126
${pytest_args[@]+"${pytest_args[@]}"}

tests/integration/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@ export CONDUCTOR_SERVER_URL="http://localhost:8080/api"
5858
./scripts/run_integration_tests.sh --with-perf
5959
```
6060

61+
By default this runs the fast `core` bucket and **skips the slowest tests**
62+
(a few tests deliberately sleep ~50-90s to exercise lease-extension timeouts and
63+
one aggregate `test_all`). Those live in their own buckets, each of which runs as
64+
a separate parallel CI job. Select one with `--bucket=<name>`:
65+
66+
| Bucket | What it runs |
67+
| --- | --- |
68+
| `core` (default) | everything except the slow buckets below |
69+
| `long-sync` | sync lease-extension tests (`test_lease_extension.py`, ~90s) |
70+
| `long-async` | async lease-extension tests (`test_async_lease_extension.py`, ~90s) |
71+
| `test-all` | aggregate workflow-client `test_all` (`test_workflow_client_intg.py`, ~83s) |
72+
| `all` | the full suite (no bucket filtering) — for a complete local run |
73+
74+
```bash
75+
./scripts/run_integration_tests.sh # fast: skips the slow buckets
76+
./scripts/run_integration_tests.sh --bucket=long-sync
77+
./scripts/run_integration_tests.sh --bucket=all # run everything
78+
```
79+
6180
Any extra arguments pass straight through to pytest, which is handy for
6281
targeting a subset of tests or getting more detail on failures. See additional
6382
options and examples in the comments at the top of

tests/integration/client/orkes/test_orkes_clients.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import time
23

34
from shortuuid import uuid
45

@@ -38,6 +39,19 @@
3839
TEST_IP_JSON = 'tests/integration/resources/test_data/loan_workflow_input.json'
3940

4041

42+
def _retry_on_404(func, *args, retries=5, **kwargs):
43+
# Updating a task by ref name can transiently 404 while the server is still
44+
# scheduling the referenced task. Retry with backoff to tolerate that race.
45+
for attempt in range(retries):
46+
try:
47+
return func(*args, **kwargs)
48+
except ApiException as e:
49+
if e.status == 404 and attempt < retries - 1:
50+
time.sleep(1 << attempt)
51+
continue
52+
raise
53+
54+
4155
class TestOrkesClients:
4256
def __init__(self, configuration: Configuration):
4357
self.api_client = ApiClient(configuration)
@@ -576,15 +590,17 @@ def __test_task_execution_lifecycle(self):
576590

577591
polledTask = batchPolledTasks[0]
578592
# Update first task of second workflow
579-
self.task_client.update_task_by_ref_name(
593+
_retry_on_404(
594+
self.task_client.update_task_by_ref_name,
580595
workflow_uuid_2,
581596
polledTask.reference_task_name,
582597
"COMPLETED",
583598
"task 2 op 2nd wf"
584599
)
585600

586601
# Update second task of first workflow
587-
self.task_client.update_task_by_ref_name(
602+
_retry_on_404(
603+
self.task_client.update_task_by_ref_name,
588604
workflow_uuid_2, "simple_task_ref_2", "COMPLETED", "task 2 op 1st wf"
589605
)
590606

@@ -593,7 +609,8 @@ def __test_task_execution_lifecycle(self):
593609
polledTask = self.task_client.poll_task(TASK_TYPE)
594610

595611
# Update second task of second workflow
596-
self.task_client.update_task_sync(
612+
_retry_on_404(
613+
self.task_client.update_task_sync,
597614
workflow_uuid, "simple_task_ref_2", "COMPLETED", "task 1 op 2nd wf"
598615
)
599616

tests/integration/metadata/test_workflow_definition.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import time
12
from typing import List
23

34
from conductor.client.http.models import TaskDef
5+
from conductor.client.http.rest import ApiException
46
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
57
from conductor.client.workflow.conductor_workflow import ConductorWorkflow
68
from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
@@ -68,7 +70,23 @@ def test_kitchensink_workflow_registration(workflow_executor: WorkflowExecutor)
6870
if type(workflow_id) != str or workflow_id == '':
6971
raise Exception(f'failed to start workflow, name: {WORKFLOW_NAME}')
7072

71-
workflow_executor.terminate(workflow_id=workflow_id, reason="End test")
73+
_terminate_with_retry(workflow_executor, workflow_id, reason="End test")
74+
75+
76+
def _terminate_with_retry(workflow_executor: WorkflowExecutor, workflow_id: str,
77+
reason: str, retries: int = 5) -> None:
78+
# Terminating a workflow that the server is still evaluating can transiently
79+
# return 423 Locked ("Unable to acquire lock"). Retry with backoff so the
80+
# test tolerates that race instead of failing.
81+
for attempt in range(retries):
82+
try:
83+
workflow_executor.terminate(workflow_id=workflow_id, reason=reason)
84+
return
85+
except ApiException as e:
86+
if e.status == 423 and attempt < retries - 1:
87+
time.sleep(1 << attempt)
88+
continue
89+
raise
7290

7391

7492
def generate_simple_task(id: int) -> SimpleTask:

tests/integration/test_async_lease_extension.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
import time
2727
import unittest
2828

29+
import pytest
30+
2931
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3032

3133
from conductor.client.automator.task_handler import TaskHandler, get_registered_workers
@@ -138,6 +140,7 @@ async def async_lease_fast_no_hb(job_id: str) -> dict:
138140

139141
# -- Test class --------------------------------------------------------------
140142

143+
@pytest.mark.slow_async
141144
class TestAsyncLeaseExtension(unittest.TestCase):
142145

143146
# Only the workers defined in THIS module. Used to scope the TaskHandler so

tests/integration/test_authorization_client_intg.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -532,7 +532,9 @@ def test_38_create_role(self):
532532

533533
request = CreateOrUpdateRoleRequest()
534534
request.name = self.test_role_name
535-
request.permissions = ["workflow:read"]
535+
# Permission strings must be in the server's ACCESS_RESOURCE form
536+
# (e.g. READ_WORKFLOW_DEF); "workflow:read" is rejected as invalid.
537+
request.permissions = ["READ_WORKFLOW_DEF"]
536538

537539
result = self.client.create_role(request)
538540

@@ -553,7 +555,8 @@ def test_40_update_role(self):
553555

554556
request = CreateOrUpdateRoleRequest()
555557
request.name = self.test_role_name
556-
request.permissions = ["workflow:read", "workflow:execute"]
558+
# Valid ACCESS_RESOURCE permission strings (see test_38_create_role).
559+
request.permissions = ["READ_WORKFLOW_DEF", "EXECUTE_WORKFLOW_DEF"]
557560

558561
result = self.client.update_role(self.test_role_name, request)
559562

tests/integration/test_authorization_complete.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,9 @@ def test_user_lifecycle(self, auth_client, test_run_id, cleanup_tracker):
292292
target_type="WORKFLOW_DEF",
293293
target_id="test-workflow"
294294
)
295-
assert isinstance(result, bool)
295+
# The server returns a per-access permission map
296+
# (e.g. {'CREATE': True, 'READ': True, ...}), not a single bool.
297+
assert isinstance(result, dict)
296298

297299

298300
class TestGroupManagement:

tests/integration/test_comprehensive_e2e.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import os
2929
import sys
3030
import time
31-
import threading
3231
import unittest
3332
from dataclasses import dataclass
3433
from typing import Optional, List, Dict, Union
@@ -144,6 +143,7 @@ def setUpClass(cls):
144143
)
145144

146145
cls.workers_started = False
146+
cls.task_handler = None
147147

148148
def test_01_create_workflow(self):
149149
"""Create test workflow."""
@@ -174,22 +174,23 @@ def test_01_create_workflow(self):
174174
def test_02_start_workers(self):
175175
"""Start workers and verify they initialize."""
176176
print("\n" + "="*80 + "\nTEST 2: Start Workers\n" + "="*80)
177-
178-
def run_workers():
179-
with TaskHandler(
180-
configuration=self.config,
181-
metrics_settings=self.metrics_settings,
182-
scan_for_annotated_workers=True,
183-
event_listeners=[self.event_collector]
184-
) as handler:
185-
handler.start_processes()
186-
time.sleep(90) # Run for test duration
187-
handler.stop_processes()
188-
189-
thread = threading.Thread(target=run_workers, daemon=True)
190-
thread.start()
177+
178+
# Start the workers and keep the handler on the class so it stays alive
179+
# for the remaining tests and is stopped deterministically in
180+
# tearDownClass. (A previous version ran the handler in a daemon thread
181+
# with a fixed 90s sleep; if the suite finished sooner, the worker
182+
# processes were never stopped and the interpreter hung at exit joining
183+
# them.)
184+
handler = TaskHandler(
185+
configuration=self.config,
186+
metrics_settings=self.metrics_settings,
187+
scan_for_annotated_workers=True,
188+
event_listeners=[self.event_collector]
189+
)
190+
handler.start_processes()
191+
self.__class__.task_handler = handler
191192
time.sleep(5) # Wait for startup
192-
193+
193194
print("✓ Workers started")
194195
self.__class__.workers_started = True
195196
self.assertTrue(self.workers_started)
@@ -422,6 +423,10 @@ def test_08_summary_assertions(self):
422423

423424
@classmethod
424425
def tearDownClass(cls):
426+
handler = getattr(cls, 'task_handler', None)
427+
if handler is not None:
428+
handler.stop_processes()
429+
cls.task_handler = None
425430
if os.path.exists(cls.metrics_dir):
426431
import shutil
427432
shutil.rmtree(cls.metrics_dir)

0 commit comments

Comments
 (0)