Skip to content

Commit 2094473

Browse files
affandarCopilot
andcommitted
feat: bump duroxide 0.1.23, add 5 e2e tests + tag constants
- Bump duroxide core from 0.1.22 to 0.1.23 - Bump duroxide-pg from local path to published 0.1.24 - Export MAX_WORKER_TAGS (5) and MAX_TAG_NAME_BYTES (256) constants - Add 5 e2e tests: heterogeneous workers, starvation-safe fallback, dual runtime cooperation, nested error handling, error recovery - Version 0.1.13 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 488d5dc commit 2094473

6 files changed

Lines changed: 263 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.13] - 2026-03-08
9+
10+
### Added
11+
- 5 new e2e tests: heterogeneous workers pipeline, starvation-safe tagged activity fallback, dual runtime tag cooperation, nested error handling propagation, error recovery with logging.
12+
- `MAX_WORKER_TAGS` (5) and `MAX_TAG_NAME_BYTES` (256) constants exported.
13+
14+
### Changed
15+
- Bumped `duroxide` core from 0.1.22 to 0.1.23 (activity tag ack validation test).
16+
- Bumped `duroxide-pg` from local path to published 0.1.24 (tag routing + migration fixes).
17+
818
## [0.1.10] - 2026-02-28
919

1020
### Added

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
[package]
22
name = "duroxide-python"
3-
version = "0.1.12"
3+
version = "0.1.13"
44
edition = "2021"
55

66
[lib]
77
name = "duroxide_python"
88
crate-type = ["cdylib"]
99

1010
[dependencies]
11-
duroxide = { version = "0.1.22", features = ["sqlite"] }
12-
duroxide-pg = { path = "../../providers/duroxide-pg" }
11+
duroxide = { version = "0.1.23", features = ["sqlite"] }
12+
duroxide-pg = { version = "0.1.24" }
1313
pyo3 = { version = "0.23", features = ["extension-module"] }
1414
async-trait = "0.1"
1515
tokio = { version = "1", features = ["full"] }

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Write durable workflows as Python generators. The Rust runtime handles replay, p
1818
- **Custom Status**`ctx.set_custom_status()` / `ctx.reset_custom_status()` for orchestration progress reporting, `client.wait_for_status_change()` for efficient polling
1919
- **Event Queues**`ctx.dequeue_event(queue_name)` for FIFO mailbox-style message passing, `client.enqueue_event()` to send messages
2020
- **Retry on Session**`ctx.schedule_activity_with_retry_on_session()` for retry with session affinity
21+
- **Tag Routing** — worker tags for activity affinity (`MAX_WORKER_TAGS=5`, `MAX_TAG_NAME_BYTES=256`)
2122
- **Admin APIs** — instance management, metrics, pruning
2223
- **Activity client access**`ctx.get_client()` lets activities start new orchestrations
2324
- **Runtime metrics**`metrics_snapshot()` for orchestration/activity counters
@@ -211,7 +212,7 @@ pip install maturin pytest
211212
# Build the native extension and install in development mode
212213
maturin develop
213214

214-
# Run all 54 tests
215+
# Run all 59 tests
215216
pytest
216217

217218
# Run tests with verbose output

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "duroxide"
7-
version = "0.1.12"
7+
version = "0.1.13"
88
description = "Python SDK for the Duroxide durable execution runtime"
99
readme = "README.md"
1010
license = { text = "MIT" }

python/duroxide/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,11 @@ def metrics_snapshot(self):
421421
return self._native.metrics_snapshot()
422422

423423

424+
# Tag limits
425+
MAX_WORKER_TAGS = 5
426+
MAX_TAG_NAME_BYTES = 256
427+
428+
424429
class TagFilter:
425430
"""Helper for constructing worker tag filter values.
426431
@@ -471,4 +476,6 @@ def default_and(tags: list) -> str:
471476
"PyEvent",
472477
"init_tracing",
473478
"parse_result",
479+
"MAX_WORKER_TAGS",
480+
"MAX_TAG_NAME_BYTES",
474481
]

tests/test_tags.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,246 @@ def pg_provider():
248248
return PostgresProvider.connect_with_schema(db_url, "duroxide_python_tags")
249249

250250

251+
def test_heterogeneous_workers_gpu_cpu_untagged():
252+
"""Fan-out: GPU render → CPU encode → untagged upload via DefaultAnd filter."""
253+
provider = SqliteProvider.in_memory()
254+
client = Client(provider)
255+
runtime = Runtime(
256+
provider,
257+
PyRuntimeOptions(
258+
dispatcher_poll_interval_ms=50,
259+
worker_tag_filter=TagFilter.default_and(["gpu", "cpu"]),
260+
),
261+
)
262+
263+
@runtime.register_activity("Render")
264+
def render(ctx, input):
265+
return f"rendered:{input}"
266+
267+
@runtime.register_activity("Encode")
268+
def encode(ctx, input):
269+
return f"encoded:{input}"
270+
271+
@runtime.register_activity("Upload")
272+
def upload(ctx, input):
273+
return f"uploaded:{input}"
274+
275+
@runtime.register_orchestration("VideoPipeline")
276+
def video_pipeline(ctx, input):
277+
rendered = yield ctx.schedule_activity("Render", "frame42").with_tag("gpu")
278+
encoded = yield ctx.schedule_activity("Encode", rendered).with_tag("cpu")
279+
uploaded = yield ctx.schedule_activity("Upload", encoded)
280+
return uploaded
281+
282+
runtime.start()
283+
try:
284+
client.start_orchestration("video-1", "VideoPipeline", "")
285+
result = client.wait_for_orchestration("video-1", 10_000)
286+
assert result.status == "Completed"
287+
assert result.output == "uploaded:encoded:rendered:frame42"
288+
finally:
289+
runtime.shutdown(100)
290+
291+
292+
def test_starvation_safe_tagged_activity_timeout_fallback():
293+
"""Race tagged activity vs timer; timer wins when no GPU worker exists."""
294+
provider = SqliteProvider.in_memory()
295+
client = Client(provider)
296+
runtime = Runtime(
297+
provider,
298+
PyRuntimeOptions(
299+
dispatcher_poll_interval_ms=50,
300+
worker_tag_filter=TagFilter.DEFAULT_ONLY,
301+
),
302+
)
303+
304+
@runtime.register_activity("GpuInference")
305+
def gpu_inference(ctx, input):
306+
return f"inference:{input}"
307+
308+
@runtime.register_activity("CpuFallback")
309+
def cpu_fallback(ctx, input):
310+
return f"cpu_fallback:{input}"
311+
312+
@runtime.register_orchestration("InferenceWithFallback")
313+
def inference_with_fallback(ctx, input):
314+
gpu_task = ctx.schedule_activity("GpuInference", input).with_tag("gpu")
315+
timeout = ctx.schedule_timer(500)
316+
winner = yield ctx.race(gpu_task, timeout)
317+
if winner["index"] == 0:
318+
return winner["value"]
319+
else:
320+
result = yield ctx.schedule_activity("CpuFallback", input)
321+
return result
322+
323+
runtime.start()
324+
try:
325+
client.start_orchestration("infer-1", "InferenceWithFallback", "model-v3")
326+
result = client.wait_for_orchestration("infer-1", 10_000)
327+
assert result.status == "Completed"
328+
assert result.output == "cpu_fallback:model-v3"
329+
finally:
330+
runtime.shutdown(100)
331+
332+
333+
def test_dual_runtime_orchestrator_plus_gpu_worker():
334+
"""Two runtimes on same store: RT-A dispatches + CPU, RT-B handles GPU tags."""
335+
provider = SqliteProvider.in_memory()
336+
client = Client(provider)
337+
338+
# Runtime A: orchestrator + default (CPU) worker
339+
rt_a = Runtime(
340+
provider,
341+
PyRuntimeOptions(
342+
dispatcher_poll_interval_ms=50,
343+
worker_tag_filter=TagFilter.DEFAULT_ONLY,
344+
),
345+
)
346+
347+
@rt_a.register_activity("PreProcess")
348+
def preprocess_a(ctx, input):
349+
return f"preprocessed:{input}"
350+
351+
@rt_a.register_activity("GpuTrain")
352+
def gpu_train_a(ctx, input):
353+
return f"trained:{input}"
354+
355+
@rt_a.register_activity("SaveModel")
356+
def save_model_a(ctx, input):
357+
return f"saved:{input}"
358+
359+
@rt_a.register_orchestration("MLPipeline")
360+
def ml_pipeline_a(ctx, input):
361+
preprocessed = yield ctx.schedule_activity("PreProcess", input)
362+
model = yield ctx.schedule_activity("GpuTrain", preprocessed).with_tag("gpu")
363+
saved = yield ctx.schedule_activity("SaveModel", model)
364+
return saved
365+
366+
# Runtime B: GPU worker only (no orchestration dispatcher)
367+
rt_b = Runtime(
368+
provider,
369+
PyRuntimeOptions(
370+
dispatcher_poll_interval_ms=50,
371+
orchestration_concurrency=0,
372+
worker_tag_filter=TagFilter.tags(["gpu"]),
373+
),
374+
)
375+
376+
@rt_b.register_activity("PreProcess")
377+
def preprocess_b(ctx, input):
378+
return f"preprocessed:{input}"
379+
380+
@rt_b.register_activity("GpuTrain")
381+
def gpu_train_b(ctx, input):
382+
return f"trained:{input}"
383+
384+
@rt_b.register_activity("SaveModel")
385+
def save_model_b(ctx, input):
386+
return f"saved:{input}"
387+
388+
@rt_b.register_orchestration("MLPipeline")
389+
def ml_pipeline_b(ctx, input):
390+
preprocessed = yield ctx.schedule_activity("PreProcess", input)
391+
model = yield ctx.schedule_activity("GpuTrain", preprocessed).with_tag("gpu")
392+
saved = yield ctx.schedule_activity("SaveModel", model)
393+
return saved
394+
395+
rt_a.start()
396+
rt_b.start()
397+
try:
398+
client.start_orchestration("ml-1", "MLPipeline", "dataset-v5")
399+
result = client.wait_for_orchestration("ml-1", 10_000)
400+
assert result.status == "Completed"
401+
assert result.output == "saved:trained:preprocessed:dataset-v5"
402+
finally:
403+
rt_b.shutdown(100)
404+
rt_a.shutdown(100)
405+
406+
407+
def test_nested_error_handling_propagation():
408+
"""Activity error propagates through orchestration via yield."""
409+
provider = SqliteProvider.in_memory()
410+
client = Client(provider)
411+
runtime = Runtime(provider, PyRuntimeOptions(dispatcher_poll_interval_ms=50))
412+
413+
@runtime.register_activity("ProcessData")
414+
def process_data(ctx, input):
415+
if "error" in input:
416+
raise Exception("Processing failed")
417+
return f"Processed: {input}"
418+
419+
@runtime.register_activity("FormatOutput")
420+
def format_output(ctx, input):
421+
return f"Final: {input}"
422+
423+
@runtime.register_orchestration("NestedErrorHandling")
424+
def nested_error_handling(ctx, input):
425+
processed = yield ctx.schedule_activity("ProcessData", input)
426+
formatted = yield ctx.schedule_activity("FormatOutput", processed)
427+
return formatted
428+
429+
runtime.start()
430+
try:
431+
# Success case
432+
client.start_orchestration("nested-ok", "NestedErrorHandling", "test")
433+
ok = client.wait_for_orchestration("nested-ok", 5_000)
434+
assert ok.status == "Completed"
435+
assert ok.output == "Final: Processed: test"
436+
437+
# Error case
438+
client.start_orchestration("nested-err", "NestedErrorHandling", "error")
439+
err = client.wait_for_orchestration("nested-err", 5_000)
440+
assert err.status == "Failed"
441+
assert "Processing failed" in err.error
442+
finally:
443+
runtime.shutdown(100)
444+
445+
446+
def test_error_recovery_with_logging():
447+
"""Activity error caught, logged via another activity, then re-raised."""
448+
provider = SqliteProvider.in_memory()
449+
client = Client(provider)
450+
runtime = Runtime(provider, PyRuntimeOptions(dispatcher_poll_interval_ms=50))
451+
452+
@runtime.register_activity("ProcessData")
453+
def process_data(ctx, input):
454+
if "error" in input:
455+
raise Exception("Processing failed")
456+
return f"Processed: {input}"
457+
458+
@runtime.register_activity("LogError")
459+
def log_error(ctx, error):
460+
return f"Logged: {error}"
461+
462+
@runtime.register_orchestration("ErrorRecovery")
463+
def error_recovery(ctx, input):
464+
try:
465+
result = yield ctx.schedule_activity("ProcessData", input)
466+
return result
467+
except Exception as e:
468+
yield ctx.schedule_activity("LogError", str(e))
469+
raise Exception(f"Failed to process '{input}': {e}")
470+
471+
runtime.start()
472+
try:
473+
# Success case
474+
client.start_orchestration("recovery-ok", "ErrorRecovery", "test")
475+
ok = client.wait_for_orchestration("recovery-ok", 5_000)
476+
assert ok.status == "Completed"
477+
assert ok.output == "Processed: test"
478+
479+
# Error recovery case
480+
client.start_orchestration("recovery-err", "ErrorRecovery", "error")
481+
err = client.wait_for_orchestration("recovery-err", 5_000)
482+
assert err.status == "Failed"
483+
assert "Failed to process 'error'" in err.error
484+
finally:
485+
runtime.shutdown(100)
486+
487+
488+
# ─── PostgreSQL Tests ──────────────────────────────────────────────
489+
490+
251491
def test_pg_tagged_activity(pg_provider):
252492
"""Full PostgreSQL test for tagged activity routing."""
253493
client = Client(pg_provider)

0 commit comments

Comments
 (0)