Skip to content

Commit 8664294

Browse files
wangyb-AAlex Wang
andauthored
feat: context aware replay status (#488)
- Add per-context replay status - Add replay status for map and parallel branches - Use per-context replay for hooks - Add logging assertion for examples --------- Co-authored-by: Alex Wang <wangyb@amazon.com>
1 parent 3228220 commit 8664294

17 files changed

Lines changed: 1537 additions & 348 deletions

File tree

packages/aws-durable-execution-sdk-python-examples/examples-catalog.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,36 @@
280280
},
281281
"path": "./src/logger_example/logger_example.py"
282282
},
283+
{
284+
"name": "Replay Logging",
285+
"description": "Demonstrating replay-aware logger de-duplication across a wait/replay boundary",
286+
"handler": "replay_logging.handler",
287+
"integration": true,
288+
"durableConfig": {
289+
"RetentionPeriodInDays": 7,
290+
"ExecutionTimeout": 300
291+
},
292+
"loggingConfig": {
293+
"ApplicationLogLevel": "INFO",
294+
"LogFormat": "JSON"
295+
},
296+
"path": "./src/logger_example/replay_logging.py"
297+
},
298+
{
299+
"name": "Replay Logging Concurrent",
300+
"description": "Demonstrating per-branch replay-aware logger de-duplication across concurrent parallel branch waits",
301+
"handler": "replay_logging_concurrent.handler",
302+
"integration": true,
303+
"durableConfig": {
304+
"RetentionPeriodInDays": 7,
305+
"ExecutionTimeout": 300
306+
},
307+
"loggingConfig": {
308+
"ApplicationLogLevel": "INFO",
309+
"LogFormat": "JSON"
310+
},
311+
"path": "./src/logger_example/replay_logging_concurrent.py"
312+
},
283313
{
284314
"name": "Steps with Retry",
285315
"description": "Multiple steps with retry logic in a polling pattern",
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Example demonstrating replay-aware logging across a wait boundary."""
2+
3+
from typing import Any
4+
5+
from aws_durable_execution_sdk_python.config import Duration
6+
from aws_durable_execution_sdk_python.context import (
7+
DurableContext,
8+
StepContext,
9+
durable_step,
10+
durable_with_child_context,
11+
)
12+
from aws_durable_execution_sdk_python.execution import durable_execution
13+
14+
15+
@durable_step
16+
def prepare(step_context: StepContext, item: str) -> str:
17+
"""A step that runs before the wait.
18+
19+
Its log is emitted on the first invocation. On replay this step is not
20+
re-executed (it returns its checkpointed result), so this log does not
21+
repeat.
22+
"""
23+
step_context.logger.info("Preparing item", extra={"item": item})
24+
return f"prepared:{item}"
25+
26+
27+
@durable_step
28+
def finalize(step_context: StepContext, prepared: str) -> str:
29+
"""A step that runs after the wait (new work on the replay invocation)."""
30+
step_context.logger.info("Finalizing item", extra={"prepared": prepared})
31+
return f"done:{prepared}"
32+
33+
34+
@durable_with_child_context
35+
def audit(child_ctx: DurableContext, prepared: str) -> str:
36+
"""Child context with its own logger and its own replay status."""
37+
child_ctx.logger.info(
38+
"Auditing in child context (before child wait)",
39+
extra={"prepared": prepared, "child_is_replaying": child_ctx.is_replaying()},
40+
)
41+
42+
# The child's own replay boundary.
43+
child_ctx.wait(duration=Duration.from_seconds(5), name="audit_cooldown")
44+
45+
# After the child's wait: emitted as new work on the child's replay.
46+
child_ctx.logger.info(
47+
"Resumed in child context (after child wait)",
48+
extra={"child_is_replaying": child_ctx.is_replaying()},
49+
)
50+
51+
return child_ctx.step(lambda _: f"audited:{prepared}", name="record_audit")
52+
53+
54+
@durable_execution
55+
def handler(event: Any, context: DurableContext) -> dict[str, Any]:
56+
"""Handler demonstrating replay-aware logging across a wait."""
57+
item: str = event.get("item", "widget") if isinstance(event, dict) else "widget"
58+
59+
# --- Before the wait ---
60+
# On the replay invocation these lines are de-duplicated by the replay-aware
61+
# logger because the context is still replaying when it reaches them.
62+
context.logger.info(
63+
"Workflow started (before wait)",
64+
extra={"item": item, "is_replaying": context.is_replaying()},
65+
)
66+
67+
prepared: str = context.step(prepare(item), name="prepare")
68+
69+
context.logger.info(
70+
"Prepared, about to wait",
71+
extra={"prepared": prepared, "is_replaying": context.is_replaying()},
72+
)
73+
74+
# --- The replay boundary ---
75+
# The wait suspends the execution. When it resumes, the handler replays from
76+
# the top; everything above is de-duplicated, and everything below is new.
77+
context.wait(duration=Duration.from_seconds(5), name="cooldown")
78+
79+
# --- After the wait ---
80+
# These logs are emitted on the replay invocation because the context has
81+
# crossed its replay boundary and is no longer replaying.
82+
context.logger.info(
83+
"Resumed after wait",
84+
extra={"is_replaying": context.is_replaying()},
85+
)
86+
87+
audited: str = context.run_in_child_context(audit(prepared), name="audit")
88+
89+
result: str = context.step(finalize(audited), name="finalize")
90+
91+
context.logger.info("Workflow completed", extra={"result": result})
92+
93+
return {"result": result, "item": item}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Replay-aware logging across concurrent (parallel) branch replays.
2+
3+
Each parallel branch runs in its own child context with its own replay status.
4+
A branch that contains a `wait` suspends and is replayed independently of the
5+
other branches and of the parent. This example demonstrates that the
6+
replay-aware logger de-duplicates per branch:
7+
8+
- A branch's "before wait" log is emitted once (on the invocation where the
9+
branch first reaches its wait) and de-duplicated on the branch's replay.
10+
- A branch's "after wait" log is emitted once, as new work, when that branch
11+
resumes.
12+
- The branches stagger their waits so they resume on different invocations,
13+
making the per-branch independence visible in CloudWatch.
14+
15+
Deploy and invoke asynchronously, then inspect the logs. Each `branch` field
16+
identifies the emitting branch so you can confirm each line appears exactly
17+
once across all invocations.
18+
"""
19+
20+
from typing import Any
21+
22+
from aws_durable_execution_sdk_python.config import Duration, ParallelConfig
23+
from aws_durable_execution_sdk_python.context import (
24+
DurableContext,
25+
durable_parallel_branch,
26+
)
27+
from aws_durable_execution_sdk_python.execution import durable_execution
28+
29+
30+
def _make_branch(name: str, wait_seconds: int):
31+
"""Build a parallel branch that logs around its own wait boundary."""
32+
33+
@durable_parallel_branch(name=name)
34+
def branch(ctx: DurableContext) -> str:
35+
# Before the branch's wait. On this branch's replay invocation this is
36+
# de-duplicated because the branch is still replaying when it reaches
37+
# here. This line is the load-bearing case: there is no inner operation
38+
# before it, so the branch's child context relies on starting in the
39+
# correct replay status.
40+
ctx.logger.info(
41+
"branch start (before wait)",
42+
extra={"branch": name, "is_replaying": ctx.is_replaying()},
43+
)
44+
45+
# The branch's own replay boundary. Different per branch so the branches
46+
# resume on different invocations.
47+
ctx.wait(duration=Duration.from_seconds(wait_seconds), name=f"{name}_wait")
48+
49+
# After the branch's wait: emitted as new work on the branch's replay.
50+
ctx.logger.info(
51+
"branch resumed (after wait)",
52+
extra={"branch": name, "is_replaying": ctx.is_replaying()},
53+
)
54+
55+
return ctx.step(lambda _: f"{name}-done", name=f"{name}_finalize")
56+
57+
return branch
58+
59+
60+
@durable_execution
61+
def handler(event: Any, context: DurableContext) -> dict[str, Any]:
62+
"""Run concurrent branches that each log around their own wait."""
63+
context.logger.info(
64+
"workflow started",
65+
extra={"is_replaying": context.is_replaying()},
66+
)
67+
68+
results: list[str] = context.parallel(
69+
functions=[
70+
_make_branch("alpha", 3)(),
71+
_make_branch("bravo", 6)(),
72+
_make_branch("charlie", 9)(),
73+
],
74+
name="concurrent_branches",
75+
config=ParallelConfig(max_concurrency=3),
76+
).get_results()
77+
78+
context.logger.info(
79+
"workflow completed",
80+
extra={"results": results, "is_replaying": context.is_replaying()},
81+
)
82+
83+
return {"results": results}

packages/aws-durable-execution-sdk-python-examples/template.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,42 @@
492492
}
493493
}
494494
},
495+
"ReplayLogging": {
496+
"Type": "AWS::Serverless::Function",
497+
"Properties": {
498+
"CodeUri": "build/",
499+
"Handler": "replay_logging.handler",
500+
"Description": "Demonstrating replay-aware logger de-duplication across a wait/replay boundary",
501+
"Role": {
502+
"Fn::GetAtt": [
503+
"DurableFunctionRole",
504+
"Arn"
505+
]
506+
},
507+
"DurableConfig": {
508+
"RetentionPeriodInDays": 7,
509+
"ExecutionTimeout": 300
510+
}
511+
}
512+
},
513+
"ReplayLoggingConcurrent": {
514+
"Type": "AWS::Serverless::Function",
515+
"Properties": {
516+
"CodeUri": "build/",
517+
"Handler": "replay_logging_concurrent.handler",
518+
"Description": "Demonstrating per-branch replay-aware logger de-duplication across concurrent parallel branch waits",
519+
"Role": {
520+
"Fn::GetAtt": [
521+
"DurableFunctionRole",
522+
"Arn"
523+
]
524+
},
525+
"DurableConfig": {
526+
"RetentionPeriodInDays": 7,
527+
"ExecutionTimeout": 300
528+
}
529+
}
530+
},
495531
"StepsWithRetry": {
496532
"Type": "AWS::Serverless::Function",
497533
"Properties": {

packages/aws-durable-execution-sdk-python-examples/test/logger_example/test_logger_example.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Tests for logger_example."""
22

3+
import logging
4+
35
import pytest
6+
47
from aws_durable_execution_sdk_python.execution import InvocationStatus
58
from aws_durable_execution_sdk_python.lambda_service import OperationType
6-
79
from src.logger_example import logger_example
810
from test.conftest import deserialize_operation_payload
911

@@ -33,3 +35,46 @@ def test_logger_example(durable_runner):
3335
op for op in result.operations if op.operation_type.value == "CONTEXT"
3436
]
3537
assert len(context_ops) >= 1
38+
39+
40+
@pytest.mark.example
41+
@pytest.mark.durable_execution(
42+
handler=logger_example.handler,
43+
lambda_function_name="logger example",
44+
)
45+
def test_logger_example_emits_expected_logs(durable_runner, caplog):
46+
"""Verify the durable logger emits the expected messages and enriched extras.
47+
48+
The SDK logger wraps the stdlib root logger, so ``caplog`` captures every
49+
record emitted from both the top-level context and from inside steps. Step
50+
logs are enriched with the operation name and attempt number, which we
51+
assert on via the LogRecord attributes.
52+
53+
Log capture only works in local mode (the handler runs in-process); in cloud
54+
mode the handler runs in a deployed Lambda, so this test is skipped there.
55+
"""
56+
if durable_runner.mode != "local":
57+
pytest.skip("Log capture is only available in local (in-process) mode")
58+
59+
with caplog.at_level(logging.INFO):
60+
with durable_runner:
61+
result = durable_runner.run(input={"id": "test-123"}, timeout=10)
62+
63+
assert result.status is InvocationStatus.SUCCEEDED
64+
65+
messages = [record.getMessage() for record in caplog.records]
66+
# Top-level context logs (no step_id) and in-step logs both appear.
67+
assert "Starting workflow" in messages
68+
assert "Hello from my_step" in messages
69+
assert "Workflow completed" in messages
70+
71+
# The warning emitted inside my_step is enriched with the step's extra
72+
# (my_arg) and with the operation name the SDK attaches automatically.
73+
warning = next(
74+
record
75+
for record in caplog.records
76+
if record.getMessage() == "Warning from my_step"
77+
)
78+
assert warning.levelno == logging.WARNING
79+
assert warning.my_arg == 123
80+
assert warning.operationName == "my_step"

0 commit comments

Comments
 (0)