-
Notifications
You must be signed in to change notification settings - Fork 14
feat: add types and helper function for context propagation #152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
timl3136
wants to merge
9
commits into
cadence-workflow:main
Choose a base branch
from
timl3136:context-propagation-primitives
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+311
−0
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
851e531
feat: add context propagation primitives
timl3136 0d8e1f2
lint
timl3136 c6a0c56
lint
timl3136 ec89303
test: cover context propagation primitives
timl3136 ca24fe5
Merge branch 'context-propagation-primitives' of github.com:timl3136/…
timl3136 954a262
Merge branch 'main' into context-propagation-primitives
timl3136 16c5b4f
fix: preserve context propagation errors
timl3136 2590500
lint
timl3136 f475970
Merge branch 'context-propagation-primitives' of github.com:timl3136/…
timl3136 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| """Shared Cadence header codecs and context-propagation scopes.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Iterator, Mapping, Sequence | ||
| from contextlib import ExitStack, contextmanager | ||
|
|
||
| from cadence.api.v1.common_pb2 import Header | ||
| from cadence.context import ContextPropagator | ||
| from cadence.error import ContextPropagationError | ||
|
|
||
|
|
||
| def normalize_context_propagators( | ||
| propagators: Sequence[ContextPropagator] | None, | ||
| ) -> tuple[ContextPropagator, ...]: | ||
| """Snapshot propagators so runtime behavior cannot change after setup.""" | ||
| return tuple(propagators or ()) | ||
|
|
||
|
|
||
| def header_to_context_fields(header: Header | None) -> dict[str, bytes]: | ||
| """Convert a Cadence Header to the cross-SDK ``str -> bytes`` carrier.""" | ||
| if header is None: | ||
| return {} | ||
| return {key: bytes(payload.data) for key, payload in header.fields.items()} | ||
|
|
||
|
|
||
| def inject_context_fields( | ||
| propagators: Sequence[ContextPropagator], | ||
| ) -> dict[str, bytes]: | ||
| """Collect and validate fields emitted by configured propagators.""" | ||
| fields: dict[str, bytes] = {} | ||
| for propagator in propagators: | ||
| try: | ||
| emitted = propagator.inject() | ||
| except ContextPropagationError: | ||
| raise | ||
| except Exception as exc: | ||
| raise ContextPropagationError( | ||
| f"Context propagation inject failed for {type(propagator).__name__}" | ||
| ) from exc | ||
|
gitar-bot[bot] marked this conversation as resolved.
|
||
| if not isinstance(emitted, Mapping): | ||
| raise ContextPropagationError( | ||
| f"{type(propagator).__name__}.inject() must return a mapping" | ||
| ) | ||
| for key, value in emitted.items(): | ||
| if not isinstance(key, str): | ||
| raise ContextPropagationError( | ||
| f"{type(propagator).__name__}.inject() emitted a non-string key" | ||
| ) | ||
| if not isinstance(value, (bytes, bytearray, memoryview)): | ||
| raise ContextPropagationError( | ||
| f"{type(propagator).__name__}.inject() emitted a non-bytes value " | ||
| f"for header {key!r}" | ||
| ) | ||
| if key in fields: | ||
| raise ContextPropagationError( | ||
| f"Multiple context propagators emitted header {key!r}" | ||
| ) | ||
| fields[key] = bytes(value) | ||
| return fields | ||
|
|
||
|
|
||
| def context_header_from_propagators( | ||
| propagators: Sequence[ContextPropagator], | ||
| ) -> Header | None: | ||
| """Build an independent protobuf Header for outbound propagation.""" | ||
| fields = inject_context_fields(propagators) | ||
| if not fields: | ||
| return None | ||
| header = Header() | ||
| for key, value in fields.items(): | ||
| header.fields[key].data = value | ||
| return header | ||
|
|
||
|
|
||
| @contextmanager | ||
| def context_propagation_scope( | ||
| propagators: Sequence[ContextPropagator], | ||
| header_or_fields: Header | Mapping[str, bytes] | None, | ||
| ) -> Iterator[None]: | ||
| """Install incoming context for one logical workflow or activity invocation.""" | ||
| fields: Mapping[str, bytes] | ||
| if isinstance(header_or_fields, Header): | ||
| fields = header_to_context_fields(header_or_fields) | ||
| elif header_or_fields is None: | ||
| fields = {} | ||
| else: | ||
| fields = header_or_fields | ||
|
|
||
| stack = ExitStack() | ||
| try: | ||
| for propagator in propagators: | ||
| try: | ||
| scope = propagator.extract(fields) | ||
| stack.enter_context(scope) | ||
| except ContextPropagationError: | ||
| raise | ||
| except Exception as exc: | ||
| raise ContextPropagationError( | ||
| f"Context propagation extract failed for {type(propagator).__name__}" | ||
| ) from exc | ||
| except Exception: | ||
| try: | ||
| stack.close() | ||
| except Exception as cleanup_error: | ||
| raise ContextPropagationError( | ||
| "Context propagation cleanup failed after extract error" | ||
| ) from cleanup_error | ||
| raise | ||
|
|
||
| try: | ||
| yield | ||
| finally: | ||
| try: | ||
| stack.close() | ||
| except Exception as exc: | ||
| raise ContextPropagationError("Context propagation cleanup failed") from exc | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """Context propagation interfaces for Cadence workflow boundaries. | ||
|
|
||
| Propagators carry application-owned, request-scoped metadata through Cadence | ||
| headers. Implementations commonly use :mod:`contextvars` so values are scoped | ||
| to each client, workflow, or activity invocation. | ||
| """ | ||
|
|
||
| from collections.abc import Mapping | ||
| from typing import ContextManager, Protocol | ||
|
|
||
| from cadence.error import ContextPropagationError | ||
|
|
||
| __all__ = ["ContextPropagationError", "ContextPropagator"] | ||
|
|
||
|
|
||
| class ContextPropagator(Protocol): | ||
| """Serialize and scope application context across Cadence boundaries. | ||
|
|
||
| ``inject`` is called before the SDK writes a Cadence header. ``extract`` is | ||
| called for an incoming header and must return a context manager that restores | ||
| any ambient state when its scope exits. | ||
|
|
||
| Workflow-side ``inject`` calls are replay-sensitive: implementations must | ||
| return identical bytes for identical persisted workflow state. | ||
| """ | ||
|
|
||
| def inject(self) -> Mapping[str, bytes]: | ||
| """Return the current context as opaque header fields.""" | ||
|
|
||
| def extract(self, headers: Mapping[str, bytes]) -> ContextManager[None]: | ||
| """Return a scope that installs context decoded from ``headers``.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| from collections.abc import Iterator, Mapping | ||
| from contextlib import contextmanager | ||
| from contextvars import ContextVar | ||
| from typing import cast | ||
|
|
||
| import pytest | ||
|
|
||
| from cadence._internal.context_propagation import ( | ||
| context_header_from_propagators, | ||
| context_propagation_scope, | ||
| header_to_context_fields, | ||
| normalize_context_propagators, | ||
| ) | ||
| from cadence.api.v1.common_pb2 import Header | ||
| from cadence.context import ContextPropagationError | ||
|
|
||
|
|
||
| class _ContextVarPropagator: | ||
| def __init__(self, key: str = "x-request-id") -> None: | ||
| self._key = key | ||
| self.value: ContextVar[str | None] = ContextVar(key, default=None) | ||
|
|
||
| def inject(self) -> dict[str, bytes]: | ||
| value = self.value.get() | ||
| return {} if value is None else {self._key: value.encode()} | ||
|
|
||
| @contextmanager | ||
| def extract(self, headers: Mapping[str, bytes]) -> Iterator[None]: | ||
| raw_value = headers.get(self._key) | ||
| token = self.value.set(raw_value.decode() if raw_value is not None else None) | ||
| try: | ||
| yield | ||
| finally: | ||
| self.value.reset(token) | ||
|
|
||
|
|
||
| class _StaticPropagator: | ||
| def __init__(self, fields: Mapping[str, bytes]) -> None: | ||
| self._fields = fields | ||
|
|
||
| def inject(self) -> Mapping[str, bytes]: | ||
| return self._fields | ||
|
|
||
| @contextmanager | ||
| def extract(self, _headers: Mapping[str, bytes]) -> Iterator[None]: | ||
| yield | ||
|
|
||
|
|
||
| class _BrokenPropagator: | ||
| def inject(self) -> Mapping[str, bytes]: | ||
| raise ValueError("cannot inject") | ||
|
|
||
| @contextmanager | ||
| def extract(self, headers: Mapping[str, bytes]) -> Iterator[None]: | ||
| if headers: | ||
| yield | ||
| return | ||
| raise ValueError("cannot extract") | ||
|
|
||
|
|
||
| class _ContextPropagationErrorPropagator: | ||
| def inject(self) -> Mapping[str, bytes]: | ||
| raise ContextPropagationError("propagator-specific failure") | ||
|
|
||
| @contextmanager | ||
| def extract(self, _headers: Mapping[str, bytes]) -> Iterator[None]: | ||
| yield | ||
|
|
||
|
|
||
| class _RecordingPropagator(_StaticPropagator): | ||
| def __init__(self, events: list[str], name: str) -> None: | ||
| super().__init__({}) | ||
| self._events = events | ||
| self._name = name | ||
|
|
||
| @contextmanager | ||
| def extract(self, _headers: Mapping[str, bytes]) -> Iterator[None]: | ||
| self._events.append(f"enter:{self._name}") | ||
| try: | ||
| yield | ||
| finally: | ||
| self._events.append(f"exit:{self._name}") | ||
|
|
||
|
|
||
| class _CleanupBrokenPropagator(_StaticPropagator): | ||
| @contextmanager | ||
| def extract(self, _headers: Mapping[str, bytes]) -> Iterator[None]: | ||
| try: | ||
| yield | ||
| finally: | ||
| raise ValueError("cannot clean up") | ||
|
|
||
|
|
||
| def test_header_codec_preserves_raw_bytes_and_contextvar_scope() -> None: | ||
| propagator = _ContextVarPropagator() | ||
| header = Header() | ||
| header.fields["go.trace"].data = b"\x00\xffgo" | ||
| header.fields["x-request-id"].data = b"request-1" | ||
| header.fields["java.trace"].data = b"\x80java\x00" | ||
|
|
||
| assert header_to_context_fields(header) == { | ||
| "go.trace": b"\x00\xffgo", | ||
| "x-request-id": b"request-1", | ||
| "java.trace": b"\x80java\x00", | ||
| } | ||
| with context_propagation_scope((propagator,), header): | ||
| assert propagator.value.get() == "request-1" | ||
| assert propagator.value.get() is None | ||
|
|
||
|
|
||
| def test_injection_rejects_collisions_and_invalid_values() -> None: | ||
| with pytest.raises(ContextPropagationError, match="Multiple context propagators"): | ||
| context_header_from_propagators( | ||
| ( | ||
| _StaticPropagator({"shared": b"one"}), | ||
| _StaticPropagator({"shared": b"two"}), | ||
| ) | ||
| ) | ||
|
|
||
| invalid = _StaticPropagator(cast(Mapping[str, bytes], {"invalid": "value"})) | ||
| with pytest.raises(ContextPropagationError, match="non-bytes"): | ||
| context_header_from_propagators((invalid,)) | ||
|
|
||
| with pytest.raises(ContextPropagationError, match="inject failed"): | ||
| context_header_from_propagators((_BrokenPropagator(),)) | ||
|
|
||
| with pytest.raises( | ||
| ContextPropagationError, match="propagator-specific failure" | ||
| ) as error: | ||
| context_header_from_propagators((_ContextPropagationErrorPropagator(),)) | ||
| assert str(error.value) == "propagator-specific failure" | ||
|
|
||
|
|
||
| def test_scope_reverses_cleanup_and_wraps_propagator_errors() -> None: | ||
| events: list[str] = [] | ||
| with context_propagation_scope( | ||
| (_RecordingPropagator(events, "first"), _RecordingPropagator(events, "second")), | ||
| {}, | ||
| ): | ||
| assert events == ["enter:first", "enter:second"] | ||
| assert events == ["enter:first", "enter:second", "exit:second", "exit:first"] | ||
|
|
||
| with pytest.raises(ContextPropagationError, match="extract failed"): | ||
| with context_propagation_scope((_BrokenPropagator(),), {}): | ||
| pass | ||
| with pytest.raises(ContextPropagationError, match="cleanup failed"): | ||
| with context_propagation_scope((_CleanupBrokenPropagator({}),), {}): | ||
| pass | ||
|
|
||
|
|
||
| def test_normalization_snapshots_configured_propagators() -> None: | ||
| propagator = _StaticPropagator({}) | ||
| configured = [propagator] | ||
|
|
||
| normalized = normalize_context_propagators(configured) | ||
| configured.clear() | ||
|
|
||
| assert normalized == (propagator,) | ||
| assert normalize_context_propagators(None) == () |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.