Skip to content

Commit e860c48

Browse files
affandarCopilot
andcommitted
feat: add get_custom_status() API and bump to duroxide 0.1.20
- Add get_custom_status() to OrchestrationContext for reading custom status within orchestrations (returns str or None) - Bump duroxide to 0.1.20, duroxide-pg to 0.1.22 - Bump package version to 0.1.9 (pyproject.toml), crate to 0.1.8 - Add e2e tests: basic get/set across turns + CAN boundary persistence - Update CHANGELOG.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ad817b7 commit e860c48

8 files changed

Lines changed: 142 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ 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.9] - 2026-02-21
9+
10+
### Added
11+
- **`ctx.get_custom_status()`** — read the current custom status value from within an orchestration. Returns the status string or `None` if none has been set. Reflects all `set_custom_status`/`reset_custom_status` calls, including across turn boundaries and continue-as-new.
12+
13+
### Changed
14+
- Upgraded duroxide to 0.1.20, duroxide-pg to 0.1.22
15+
816
## [0.1.8] - 2026-02-15
917

1018
### 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.7"
3+
version = "0.1.8"
44
edition = "2021"
55

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

1010
[dependencies]
11-
duroxide = { version = "0.1.19", features = ["sqlite"] }
12-
duroxide-pg = "0.1.21"
11+
duroxide = { version = "0.1.20", features = ["sqlite"] }
12+
duroxide-pg = "0.1.22"
1313
pyo3 = { version = "0.23", features = ["extension-module"] }
1414
async-trait = "0.1"
1515
tokio = { version = "1", features = ["full"] }

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.8"
7+
version = "0.1.9"
88
description = "Python SDK for the Duroxide durable execution runtime"
99
readme = "README.md"
1010
license = { text = "MIT" }

python/duroxide/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
orchestration_trace_log,
2727
orchestration_set_custom_status,
2828
orchestration_reset_custom_status,
29+
orchestration_get_custom_status,
2930
activity_is_cancelled,
3031
init_tracing,
3132
)

python/duroxide/context.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
"""
1010

1111
import json
12+
from typing import Optional
1213

1314
from duroxide._duroxide import (
1415
orchestration_trace_log,
1516
orchestration_set_custom_status,
1617
orchestration_reset_custom_status,
18+
orchestration_get_custom_status,
1719
activity_trace_log,
1820
activity_is_cancelled,
1921
)
@@ -240,6 +242,15 @@ def reset_custom_status(self):
240242
"""
241243
orchestration_reset_custom_status(self.instance_id)
242244

245+
def get_custom_status(self) -> Optional[str]:
246+
"""Read the current custom status value.
247+
248+
Returns the status string or None if none has been set.
249+
Reflects all set/reset calls made so far, including across turns
250+
and continue-as-new boundaries.
251+
"""
252+
return orchestration_get_custom_status(self.instance_id)
253+
243254
# ─── Logging (fire-and-forget, delegates to Rust ctx.trace()) ───
244255

245256
def trace_info(self, message: str):

src/handlers.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ pub fn orchestration_reset_custom_status(instance_id: &str) {
128128
}
129129
}
130130

131+
/// Called from Python to read the current custom status from the OrchestrationContext.
132+
pub fn orchestration_get_custom_status(instance_id: &str) -> Option<String> {
133+
let map = ORCHESTRATION_CTXS.lock().unwrap();
134+
map.get(instance_id).and_then(|ctx| ctx.get_custom_status())
135+
}
136+
131137
// ─── Activity Bridge ─────────────────────────────────────────────
132138

133139
/// Wraps a Python callable as a Rust ActivityHandler.

src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ fn orchestration_reset_custom_status(instance_id: String) {
4242
handlers::orchestration_reset_custom_status(&instance_id);
4343
}
4444

45+
/// Read the current custom status value from an orchestration context.
46+
/// Returns None if no custom status has been set.
47+
#[pyfunction]
48+
fn orchestration_get_custom_status(instance_id: String) -> Option<String> {
49+
handlers::orchestration_get_custom_status(&instance_id)
50+
}
51+
4552
/// Get a Client from the stored ActivityContext (for use in activities).
4653
#[pyfunction]
4754
fn activity_get_client(token: String) -> Option<client::PyClient> {
@@ -111,6 +118,7 @@ fn _duroxide(m: &Bound<'_, PyModule>) -> PyResult<()> {
111118
m.add_function(wrap_pyfunction!(activity_is_cancelled, m)?)?;
112119
m.add_function(wrap_pyfunction!(orchestration_set_custom_status, m)?)?;
113120
m.add_function(wrap_pyfunction!(orchestration_reset_custom_status, m)?)?;
121+
m.add_function(wrap_pyfunction!(orchestration_get_custom_status, m)?)?;
114122
m.add_function(wrap_pyfunction!(activity_get_client, m)?)?;
115123
m.add_function(wrap_pyfunction!(init_tracing, m)?)?;
116124
m.add_class::<provider::PySqliteProvider>()?;

tests/test_e2e.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -756,7 +756,110 @@ def client_from_activity(ctx, input):
756756
runtime.shutdown(100)
757757

758758

759-
# ─── 26. Metrics Snapshot ───────────────────────────────────────
759+
# ─── 26. Custom Status Get ──────────────────────────────────────
760+
761+
762+
def test_custom_status_get_reflects_set_across_turns(provider):
763+
"""ctx.get_custom_status() returns correct value, including across turn boundaries."""
764+
client = Client(provider)
765+
runtime = Runtime(provider, PyRuntimeOptions(dispatcher_poll_interval_ms=50))
766+
767+
@runtime.register_activity("Echo")
768+
def echo(ctx, input):
769+
return input
770+
771+
@runtime.register_orchestration("GetterTest")
772+
def getter_test(ctx, input):
773+
# Before any set, should be None
774+
assert ctx.get_custom_status() is None, "initial should be None"
775+
776+
ctx.set_custom_status("step-1")
777+
assert ctx.get_custom_status() == "step-1", "should reflect set immediately"
778+
779+
# Cross a turn boundary
780+
yield ctx.schedule_activity("Echo", "ping")
781+
782+
# After replay, should still return "step-1"
783+
assert ctx.get_custom_status() == "step-1", "should survive replay across turns"
784+
785+
ctx.set_custom_status("step-2")
786+
assert ctx.get_custom_status() == "step-2", "should reflect second set"
787+
788+
return "done"
789+
790+
runtime.start()
791+
try:
792+
instance_id = uid("cs-getter")
793+
client.start_orchestration(instance_id, "GetterTest", "")
794+
result = client.wait_for_orchestration(instance_id, 10_000)
795+
assert result.status == "Completed"
796+
assert result.output == "done"
797+
assert result.custom_status == "step-2"
798+
finally:
799+
runtime.shutdown(100)
800+
801+
802+
def test_custom_status_persists_across_continue_as_new(provider):
803+
"""Custom status persists across CAN boundaries and can be reset."""
804+
client = Client(provider)
805+
runtime = Runtime(provider, PyRuntimeOptions(dispatcher_poll_interval_ms=50))
806+
807+
@runtime.register_activity("Echo")
808+
def echo(ctx, input):
809+
return input
810+
811+
@runtime.register_orchestration("StatusCanTest")
812+
def status_can_test(ctx, input):
813+
generation = input.get("generation", 1) if isinstance(input, dict) else 1
814+
815+
if generation == 1:
816+
# First generation: set status, verify, then CAN
817+
assert ctx.get_custom_status() is None, "gen1: initial should be None"
818+
ctx.set_custom_status("gen1-active")
819+
assert ctx.get_custom_status() == "gen1-active", "gen1: should reflect set"
820+
821+
# Cross a turn boundary to ensure persistence
822+
yield ctx.schedule_activity("Echo", "ping")
823+
assert ctx.get_custom_status() == "gen1-active", "gen1: should survive turn"
824+
825+
# CAN — runtime carries forward custom status automatically
826+
yield ctx.continue_as_new({"generation": 2})
827+
return None
828+
elif generation == 2:
829+
# Second generation: status should be carried forward from gen1
830+
assert ctx.get_custom_status() == "gen1-active", "gen2: should inherit from gen1"
831+
832+
# Update status in gen2
833+
ctx.set_custom_status("gen2-updated")
834+
assert ctx.get_custom_status() == "gen2-updated", "gen2: should reflect new set"
835+
836+
# Now reset it
837+
ctx.reset_custom_status()
838+
assert ctx.get_custom_status() is None, "gen2: should be None after reset"
839+
840+
# CAN again with no status
841+
yield ctx.continue_as_new({"generation": 3})
842+
return None
843+
else:
844+
# Third generation: status should be None (was reset before CAN)
845+
assert ctx.get_custom_status() is None, "gen3: should be None after reset+CAN"
846+
847+
ctx.set_custom_status("gen3-final")
848+
return "done"
849+
850+
runtime.start()
851+
try:
852+
instance_id = uid("cs-can")
853+
client.start_orchestration(instance_id, "StatusCanTest", {"generation": 1})
854+
result = client.wait_for_orchestration(instance_id, 15_000)
855+
assert result.status == "Completed"
856+
assert result.output == "done"
857+
assert result.custom_status == "gen3-final"
858+
finally:
859+
runtime.shutdown(100)
860+
861+
862+
# ─── 27. Metrics Snapshot ───────────────────────────────────────
760863

761864

762865
def test_metrics_snapshot(provider):

0 commit comments

Comments
 (0)