glyff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- glyff/__init__.py +15 -0
- glyff/context.py +174 -0
- glyff/decorators.py +51 -0
- glyff/exceptions.py +16 -0
- glyff/executor.py +60 -0
- glyff/interfaces.py +92 -0
- glyff/models.py +47 -0
- glyff/sequencer.py +40 -0
- glyff/serialization/__init__.py +6 -0
- glyff/serialization/json.py +48 -0
- glyff/session.py +52 -0
- glyff/stores/__init__.py +7 -0
- glyff/stores/memory.py +91 -0
- glyff/stores/memory_client.py +41 -0
- glyff-0.1.0.dist-info/METADATA +91 -0
- glyff-0.1.0.dist-info/RECORD +18 -0
- glyff-0.1.0.dist-info/WHEEL +4 -0
- glyff-0.1.0.dist-info/licenses/LICENSE +21 -0
glyff/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .decorators import engrave
|
|
2
|
+
from .interfaces import ArgsHasher, Serializer, SessionStore
|
|
3
|
+
from .models import ExecutionId, ExecutionRecord, ExecutionStatus
|
|
4
|
+
from .session import Session
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"engrave",
|
|
8
|
+
"Session",
|
|
9
|
+
"ExecutionId",
|
|
10
|
+
"ExecutionRecord",
|
|
11
|
+
"ExecutionStatus",
|
|
12
|
+
"ArgsHasher",
|
|
13
|
+
"Serializer",
|
|
14
|
+
"SessionStore",
|
|
15
|
+
]
|
glyff/context.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextvars
|
|
4
|
+
from collections.abc import Iterator, Sequence
|
|
5
|
+
from typing import Callable, overload
|
|
6
|
+
|
|
7
|
+
from .exceptions import ExecutionFailedError, YieldException
|
|
8
|
+
from .interfaces import ArgsHasher, SessionStore, Transaction
|
|
9
|
+
from .models import ExecutionId
|
|
10
|
+
from .sequencer import Sequencer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Context:
|
|
14
|
+
"""Holds the execution context for a workflow session."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
session_id: str,
|
|
19
|
+
store: SessionStore,
|
|
20
|
+
sequencer: Sequencer,
|
|
21
|
+
hasher: ArgsHasher,
|
|
22
|
+
transaction_scope_factory: Callable[[], TransactionScope],
|
|
23
|
+
) -> None:
|
|
24
|
+
self._session_id = session_id
|
|
25
|
+
self._store = store
|
|
26
|
+
self._sequencer = sequencer
|
|
27
|
+
self._hasher = hasher
|
|
28
|
+
self._transaction_scope_factory = transaction_scope_factory
|
|
29
|
+
self._tracer = ExecutionTracer()
|
|
30
|
+
self._current_transaction_scope: TransactionScope | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def store(self) -> SessionStore:
|
|
34
|
+
return self._store
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def sequencer(self) -> Sequencer:
|
|
38
|
+
return self._sequencer
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def hasher(self) -> ArgsHasher:
|
|
42
|
+
return self._hasher
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def tracer(self) -> ExecutionTracer:
|
|
46
|
+
return self._tracer
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def call_stack(self) -> CallStack:
|
|
50
|
+
return self._tracer.call_stack
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def current_execution_id(self) -> ExecutionId | None:
|
|
54
|
+
return self._tracer.current
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def in_transaction(self) -> bool:
|
|
58
|
+
"""Returns True if currently within a transaction scope."""
|
|
59
|
+
ts = self._current_transaction_scope
|
|
60
|
+
return ts is not None and ts.in_transaction
|
|
61
|
+
|
|
62
|
+
def get_transaction_scope(self) -> TransactionScope:
|
|
63
|
+
if self._current_transaction_scope is None:
|
|
64
|
+
self._current_transaction_scope = self._transaction_scope_factory()
|
|
65
|
+
return self._current_transaction_scope
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class CallStack(Sequence[ExecutionId]):
|
|
69
|
+
"""Read-only view of the execution call stack. No allocation on access."""
|
|
70
|
+
|
|
71
|
+
__slots__ = ("_data",)
|
|
72
|
+
|
|
73
|
+
def __init__(self, data: list[ExecutionId]) -> None:
|
|
74
|
+
self._data = data
|
|
75
|
+
|
|
76
|
+
@overload
|
|
77
|
+
def __getitem__(self, index: int) -> ExecutionId: ...
|
|
78
|
+
@overload
|
|
79
|
+
def __getitem__(self, index: slice) -> list[ExecutionId]: ...
|
|
80
|
+
|
|
81
|
+
def __getitem__(self, index):
|
|
82
|
+
return self._data[index]
|
|
83
|
+
|
|
84
|
+
def __len__(self) -> int:
|
|
85
|
+
return len(self._data)
|
|
86
|
+
|
|
87
|
+
def __contains__(self, item: object) -> bool:
|
|
88
|
+
return item in self._data
|
|
89
|
+
|
|
90
|
+
def __iter__(self) -> Iterator[ExecutionId]:
|
|
91
|
+
return iter(self._data)
|
|
92
|
+
|
|
93
|
+
def __repr__(self) -> str:
|
|
94
|
+
return f"CallStack({self._data!r})"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ExecutionTracer:
|
|
98
|
+
"""Records the active call stack during workflow execution."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._stack: list[ExecutionId] = []
|
|
102
|
+
self._view = CallStack(self._stack)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def call_stack(self) -> CallStack:
|
|
106
|
+
return self._view
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def current(self) -> ExecutionId | None:
|
|
110
|
+
return self._stack[-1] if self._stack else None
|
|
111
|
+
|
|
112
|
+
def start(self, execution_id: ExecutionId) -> None:
|
|
113
|
+
self._stack.append(execution_id)
|
|
114
|
+
|
|
115
|
+
def end(self) -> None:
|
|
116
|
+
self._stack.pop()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TransactionScope:
|
|
120
|
+
"""
|
|
121
|
+
Manages a transaction across a SessionStore, supporting nesting.
|
|
122
|
+
The actual commit/rollback only happens at the outermost scope.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, store: SessionStore):
|
|
126
|
+
self._store = store
|
|
127
|
+
self._level = 0
|
|
128
|
+
self._transaction: Transaction | None = None
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def in_transaction(self) -> bool:
|
|
132
|
+
"""Returns True if currently within a transaction scope."""
|
|
133
|
+
return self._level > 0
|
|
134
|
+
|
|
135
|
+
async def __aenter__(self):
|
|
136
|
+
if self._level == 0:
|
|
137
|
+
self._transaction = await self._store.begin_transaction()
|
|
138
|
+
self._level += 1
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
142
|
+
self._level -= 1
|
|
143
|
+
if self._level == 0 and self._transaction:
|
|
144
|
+
if exc_type is None or isinstance(
|
|
145
|
+
exc_val, (YieldException, ExecutionFailedError)
|
|
146
|
+
):
|
|
147
|
+
# On YieldException or ExecutionFailedError we still commit so that
|
|
148
|
+
# state (completed subtasks or the failure record) is durably saved.
|
|
149
|
+
await self._transaction.commit()
|
|
150
|
+
else:
|
|
151
|
+
await self._transaction.rollback()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_context_var: contextvars.ContextVar[Context] = contextvars.ContextVar("glyff_context")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_context() -> Context:
|
|
158
|
+
"""Retrieves the current workflow context."""
|
|
159
|
+
try:
|
|
160
|
+
return _context_var.get()
|
|
161
|
+
except LookupError:
|
|
162
|
+
raise RuntimeError(
|
|
163
|
+
"Workflow context is not set. Are you running outside a Session?"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def set_context(ctx: Context) -> contextvars.Token:
|
|
168
|
+
"""Sets the current workflow context. Returns a token that can be used to reset it."""
|
|
169
|
+
return _context_var.set(ctx)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def reset_context(token: contextvars.Token) -> None:
|
|
173
|
+
"""Resets the workflow context to a previous state using the provided token."""
|
|
174
|
+
_context_var.reset(token)
|
glyff/decorators.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
from typing import Any, Callable, ParamSpec, TypeVar, cast
|
|
4
|
+
|
|
5
|
+
from .context import get_context
|
|
6
|
+
from .executor import execute
|
|
7
|
+
from .models import ExecutionId
|
|
8
|
+
|
|
9
|
+
P = ParamSpec("P")
|
|
10
|
+
R = TypeVar("R")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def engrave(func: Callable[P, R]) -> Callable[P, R]:
|
|
14
|
+
"""
|
|
15
|
+
Decorator that makes an async method engraveable and resumable.
|
|
16
|
+
Its main responsibilities are ExecutionId creation and delegation to the
|
|
17
|
+
`executor` module.
|
|
18
|
+
"""
|
|
19
|
+
sig = inspect.signature(func)
|
|
20
|
+
task_name = getattr(func, "__qualname__", func.__name__)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
type_hints = inspect.get_annotations(func, eval_str=True)
|
|
24
|
+
return_type = type_hints.get("return", Any)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
raise TypeError(
|
|
27
|
+
f"Could not resolve type hints for {task_name}. "
|
|
28
|
+
f"Please ensure all types are correctly defined and imported. Error: {e}"
|
|
29
|
+
) from e
|
|
30
|
+
|
|
31
|
+
@functools.wraps(func)
|
|
32
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
33
|
+
ctx = get_context()
|
|
34
|
+
parent_id = ctx.current_execution_id
|
|
35
|
+
seq = await ctx.sequencer.next(parent_id, task_name)
|
|
36
|
+
args_hash = ctx.hasher.hash_args(func, sig, args, kwargs)
|
|
37
|
+
execution_id = ExecutionId(
|
|
38
|
+
parent_id=parent_id, name=task_name, sequence=seq, args_hash=args_hash
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
result = await execute(
|
|
42
|
+
ctx=ctx,
|
|
43
|
+
execution_id=execution_id,
|
|
44
|
+
func=func,
|
|
45
|
+
args=args,
|
|
46
|
+
kwargs=kwargs,
|
|
47
|
+
return_type=return_type,
|
|
48
|
+
)
|
|
49
|
+
return cast(R, result)
|
|
50
|
+
|
|
51
|
+
return cast(Callable[P, R], wrapper)
|
glyff/exceptions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class YieldException(Exception):
|
|
2
|
+
"""
|
|
3
|
+
A special exception to signal that the session should be interrupted gracefully.
|
|
4
|
+
This is not an error, but a signal to stop processing and engrave the state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExecutionFailedError(Exception):
|
|
11
|
+
"""
|
|
12
|
+
Raised when attempting to execute a task that has previously failed
|
|
13
|
+
and its failure state is engraved.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
pass
|
glyff/executor.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import traceback
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
from .context import Context
|
|
5
|
+
from .exceptions import ExecutionFailedError, YieldException
|
|
6
|
+
from .models import ExecutionStatus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def execute(
|
|
10
|
+
ctx: Context,
|
|
11
|
+
execution_id: Any,
|
|
12
|
+
func: Callable[..., Any],
|
|
13
|
+
args: tuple[Any, ...],
|
|
14
|
+
kwargs: dict[str, Any],
|
|
15
|
+
return_type: type,
|
|
16
|
+
) -> Any:
|
|
17
|
+
"""
|
|
18
|
+
Orchestrates the execution of a task, including cache checks, transaction
|
|
19
|
+
management, state recording, and exception handling.
|
|
20
|
+
"""
|
|
21
|
+
store = ctx.store
|
|
22
|
+
sequencer = ctx.sequencer
|
|
23
|
+
tracer = ctx.tracer
|
|
24
|
+
|
|
25
|
+
record = await store.get_execution_record(execution_id, return_type)
|
|
26
|
+
|
|
27
|
+
if record:
|
|
28
|
+
if record.status == ExecutionStatus.COMPLETED:
|
|
29
|
+
return record.result
|
|
30
|
+
if record.status == ExecutionStatus.FAILED:
|
|
31
|
+
original_error = Exception(
|
|
32
|
+
record.error or "Unknown previously failed error"
|
|
33
|
+
)
|
|
34
|
+
raise ExecutionFailedError(
|
|
35
|
+
f"Task {execution_id} failed previously and cannot be re-executed."
|
|
36
|
+
) from original_error
|
|
37
|
+
|
|
38
|
+
async with ctx.get_transaction_scope():
|
|
39
|
+
# Reset child sequencers for deterministic re-execution.
|
|
40
|
+
await sequencer.reset_for_call(execution_id)
|
|
41
|
+
|
|
42
|
+
execution = await store.start_execution(execution_id)
|
|
43
|
+
tracer.start(execution_id)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
result = await func(*args, **kwargs)
|
|
47
|
+
await execution.complete(result, return_type)
|
|
48
|
+
return result
|
|
49
|
+
except YieldException:
|
|
50
|
+
# Interruption is a graceful exit; don't stage failure.
|
|
51
|
+
# The state remains STARTED, allowing for resumption.
|
|
52
|
+
raise
|
|
53
|
+
except Exception as e:
|
|
54
|
+
error_str = "".join(traceback.format_exception(type(e), e, e.__traceback__))
|
|
55
|
+
await execution.fail(error_str)
|
|
56
|
+
raise ExecutionFailedError(
|
|
57
|
+
f"Task {execution_id} failed: {type(e).__name__}({e})"
|
|
58
|
+
) from e
|
|
59
|
+
finally:
|
|
60
|
+
tracer.end()
|
glyff/interfaces.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
from .models import ExecutionId, ExecutionRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Transaction(ABC):
|
|
9
|
+
"""
|
|
10
|
+
A transaction context for a SessionStore.
|
|
11
|
+
Actual commit/rollback logic is delegated to this object.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def commit(self) -> None:
|
|
16
|
+
"""Commits the transaction."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def rollback(self) -> None:
|
|
21
|
+
"""Rolls back the transaction."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Execution(ABC):
|
|
26
|
+
"""
|
|
27
|
+
Represents a single task execution, handling its outcome.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def complete(self, value: Any, return_type: type) -> None:
|
|
32
|
+
"""Marks the task as successfully completed with a result."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def fail(self, error: str) -> None:
|
|
37
|
+
"""Marks the task as failed with an error message."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Serializer(ABC):
|
|
42
|
+
"""An interface for serializing/deserializing values."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def serialize(self, value: Any, type_hint: type) -> bytes:
|
|
46
|
+
"""Serializes a value to bytes."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def deserialize(self, data: bytes, type_hint: type) -> Any:
|
|
51
|
+
"""Deserializes bytes to a value of the given type."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ArgsHasher(ABC):
|
|
56
|
+
"""An interface for creating a deterministic hash from function arguments."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def hash_args(
|
|
60
|
+
self, func: Callable, sig: inspect.Signature, args: tuple, kwargs: dict
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Creates a deterministic hash from a function's arguments."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SessionStore(ABC):
|
|
67
|
+
"""
|
|
68
|
+
Protocol for a store that persists the state and results of task calls.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def begin_transaction(self) -> Transaction:
|
|
73
|
+
"""Begins a transaction and returns a transaction object."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def start_execution(self, execution_id: ExecutionId) -> Execution:
|
|
78
|
+
"""
|
|
79
|
+
Records that a task has started and returns an execution object
|
|
80
|
+
to manage its outcome.
|
|
81
|
+
"""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
async def get_execution_record(
|
|
86
|
+
self, execution_id: ExecutionId, return_type: type
|
|
87
|
+
) -> ExecutionRecord | None:
|
|
88
|
+
"""
|
|
89
|
+
Gets the persisted state of a task.
|
|
90
|
+
The result, if any, is deserialized to the given type.
|
|
91
|
+
"""
|
|
92
|
+
...
|
glyff/models.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ExecutionId:
|
|
10
|
+
"""
|
|
11
|
+
A unique, deterministic identifier for a task call.
|
|
12
|
+
It forms a hierarchy through the 'parent_id' attribute.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
parent_id: ExecutionId | None
|
|
16
|
+
name: str
|
|
17
|
+
sequence: int
|
|
18
|
+
args_hash: str
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Generates a human-readable representation for debugging purposes only.
|
|
23
|
+
This format is NOT guaranteed to be stable or suitable for use as a persistence key.
|
|
24
|
+
"""
|
|
25
|
+
parent_info = (
|
|
26
|
+
f", parent='{self.parent_id.name}#{self.parent_id.sequence}'"
|
|
27
|
+
if self.parent_id
|
|
28
|
+
else ""
|
|
29
|
+
)
|
|
30
|
+
return f"ExecutionId(name='{self.name}', sequence={self.sequence}{parent_info})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExecutionStatus(Enum):
|
|
34
|
+
"""Represents the lifecycle state of a task."""
|
|
35
|
+
|
|
36
|
+
STARTED = auto()
|
|
37
|
+
COMPLETED = auto()
|
|
38
|
+
FAILED = auto()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ExecutionRecord:
|
|
43
|
+
"""Represents the persisted state and outcome of a single execution."""
|
|
44
|
+
|
|
45
|
+
status: ExecutionStatus
|
|
46
|
+
result: Any | None = None
|
|
47
|
+
error: str | None = None
|
glyff/sequencer.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
from .models import ExecutionId
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Sequencer:
|
|
8
|
+
"""
|
|
9
|
+
Generates sequential integers for ExecutionIds in a concurrency-safe manner.
|
|
10
|
+
Each (parent_id, name) pair has its own independent sequence.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._locks: dict[tuple[ExecutionId | None, str], asyncio.Lock] = {}
|
|
15
|
+
self._counters: dict[tuple[ExecutionId | None, str], int] = defaultdict(int)
|
|
16
|
+
self._meta_lock = asyncio.Lock()
|
|
17
|
+
|
|
18
|
+
async def next(self, parent: ExecutionId | None, name: str) -> int:
|
|
19
|
+
"""Returns the next sequence number for the given scope."""
|
|
20
|
+
key = (parent, name)
|
|
21
|
+
|
|
22
|
+
async with self._meta_lock:
|
|
23
|
+
if key not in self._locks:
|
|
24
|
+
self._locks[key] = asyncio.Lock()
|
|
25
|
+
|
|
26
|
+
async with self._locks[key]:
|
|
27
|
+
seq = self._counters[key]
|
|
28
|
+
self._counters[key] += 1
|
|
29
|
+
return seq
|
|
30
|
+
|
|
31
|
+
async def reset_for_call(self, execution_id: ExecutionId) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Resets all counters that are children of the given ExecutionId.
|
|
34
|
+
This is crucial for deterministic re-execution of a parent task.
|
|
35
|
+
"""
|
|
36
|
+
async with self._meta_lock:
|
|
37
|
+
keys_to_reset = [key for key in self._counters if key[0] == execution_id]
|
|
38
|
+
for key in keys_to_reset:
|
|
39
|
+
del self._counters[key]
|
|
40
|
+
del self._locks[key]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
from ..interfaces import ArgsHasher, Serializer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _json_stable_dumps(data: Any) -> str:
|
|
10
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonSerializer(Serializer):
|
|
14
|
+
"""A serializer using only the standard `json` module."""
|
|
15
|
+
|
|
16
|
+
def serialize(self, value: Any, type_hint: type) -> bytes:
|
|
17
|
+
return _json_stable_dumps(value).encode("utf-8")
|
|
18
|
+
|
|
19
|
+
def deserialize(self, data: bytes, type_hint: type) -> Any:
|
|
20
|
+
return json.loads(data.decode("utf-8"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonArgsHasher(ArgsHasher):
|
|
24
|
+
"""An ArgsHasher using standard JSON serialization."""
|
|
25
|
+
|
|
26
|
+
def hash_args(
|
|
27
|
+
self, func: Callable, sig: inspect.Signature, args: tuple, kwargs: dict
|
|
28
|
+
) -> str:
|
|
29
|
+
bound = sig.bind(*args, **kwargs)
|
|
30
|
+
bound.apply_defaults()
|
|
31
|
+
args_dict = {
|
|
32
|
+
name: value
|
|
33
|
+
for name, value in bound.arguments.items()
|
|
34
|
+
if sig.parameters[name].kind
|
|
35
|
+
not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
36
|
+
and name not in ("self", "cls")
|
|
37
|
+
}
|
|
38
|
+
try:
|
|
39
|
+
stable_repr = _json_stable_dumps(args_dict)
|
|
40
|
+
except TypeError as e:
|
|
41
|
+
raise TypeError(
|
|
42
|
+
f"Arguments to '{func.__name__}' could not be serialized to JSON. "
|
|
43
|
+
"Ensure all arguments are JSON-serializable. "
|
|
44
|
+
f"Original error: {e}"
|
|
45
|
+
) from e
|
|
46
|
+
hasher = hashlib.sha256()
|
|
47
|
+
hasher.update(stable_repr.encode("utf-8"))
|
|
48
|
+
return hasher.hexdigest()
|
glyff/session.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from .context import Context, TransactionScope, reset_context, set_context
|
|
2
|
+
from .interfaces import ArgsHasher, SessionStore
|
|
3
|
+
from .sequencer import Sequencer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Session:
|
|
7
|
+
"""
|
|
8
|
+
Manages the lifecycle of a workflow execution.
|
|
9
|
+
It sets up the execution context and the top-level transaction.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
id: str,
|
|
15
|
+
store: SessionStore,
|
|
16
|
+
hasher: ArgsHasher,
|
|
17
|
+
):
|
|
18
|
+
self._id = id
|
|
19
|
+
self._store = store
|
|
20
|
+
self._hasher = hasher
|
|
21
|
+
self._context: Context | None = None
|
|
22
|
+
self._context_token = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def id(self) -> str:
|
|
26
|
+
"""Returns the ID of this Session."""
|
|
27
|
+
return self._id
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def store(self) -> SessionStore:
|
|
31
|
+
"""Returns the SessionStore used by this Session."""
|
|
32
|
+
return self._store
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self) -> "Session":
|
|
35
|
+
self._context = Context(
|
|
36
|
+
session_id=self._id,
|
|
37
|
+
store=self._store,
|
|
38
|
+
sequencer=Sequencer(),
|
|
39
|
+
hasher=self._hasher,
|
|
40
|
+
transaction_scope_factory=lambda: TransactionScope(self._store),
|
|
41
|
+
)
|
|
42
|
+
self._context_token = set_context(self._context)
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
46
|
+
if self._context and self._context_token:
|
|
47
|
+
# The top-level transaction is managed by the TransactionScope
|
|
48
|
+
# obtained via get_transaction_scope(). We just need to ensure
|
|
49
|
+
# its __aexit__ is called if it was created.
|
|
50
|
+
if top_level_scope := self._context.get_transaction_scope():
|
|
51
|
+
await top_level_scope.__aexit__(exc_type, exc_val, exc_tb)
|
|
52
|
+
reset_context(self._context_token)
|
glyff/stores/__init__.py
ADDED
glyff/stores/memory.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..interfaces import Execution, Serializer, SessionStore, Transaction
|
|
7
|
+
from ..models import ExecutionId, ExecutionRecord, ExecutionStatus
|
|
8
|
+
from .memory_client import MemoryClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _MemoryTransaction(Transaction):
|
|
12
|
+
def __init__(self, client: MemoryClient):
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
async def commit(self) -> None:
|
|
16
|
+
await self._client.commit_staged()
|
|
17
|
+
|
|
18
|
+
async def rollback(self) -> None:
|
|
19
|
+
self._client.clear_staged()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _MemoryExecution(Execution):
|
|
23
|
+
def __init__(self, store: MemorySessionStore, execution_id: ExecutionId):
|
|
24
|
+
self._store = store
|
|
25
|
+
self._id = execution_id
|
|
26
|
+
|
|
27
|
+
def _id_to_key(self, id: ExecutionId, part: str) -> str:
|
|
28
|
+
return f"execution::{id.name}#{id.sequence}:{id.args_hash}::{part}"
|
|
29
|
+
|
|
30
|
+
async def complete(self, value: Any, return_type: type) -> None:
|
|
31
|
+
self._store._client.stage_write(
|
|
32
|
+
self._id_to_key(self._id, "status"), ExecutionStatus.COMPLETED
|
|
33
|
+
)
|
|
34
|
+
self._store._client.stage_write(
|
|
35
|
+
self._id_to_key(self._id, "result"),
|
|
36
|
+
self._store._serializer.serialize(value, return_type),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
async def fail(self, error: str) -> None:
|
|
40
|
+
self._store._client.stage_write(
|
|
41
|
+
self._id_to_key(self._id, "status"), ExecutionStatus.FAILED
|
|
42
|
+
)
|
|
43
|
+
self._store._client.stage_write(self._id_to_key(self._id, "error"), error)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MemorySessionStore(SessionStore):
|
|
47
|
+
"""
|
|
48
|
+
An in-memory implementation of SessionStore for testing and development.
|
|
49
|
+
This implementation is not persistent across processes.
|
|
50
|
+
It serializes values to ensure independence, mimicking persisted stores.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, client: MemoryClient, serializer: Serializer, **_):
|
|
54
|
+
self._client = client
|
|
55
|
+
self._serializer = serializer
|
|
56
|
+
self._lock = asyncio.Lock()
|
|
57
|
+
|
|
58
|
+
def _id_to_key(self, id: ExecutionId, part: str) -> str:
|
|
59
|
+
return f"execution::{id.name}#{id.sequence}:{id.args_hash}::{part}"
|
|
60
|
+
|
|
61
|
+
async def begin_transaction(self) -> Transaction:
|
|
62
|
+
return _MemoryTransaction(self._client)
|
|
63
|
+
|
|
64
|
+
async def start_execution(self, execution_id: ExecutionId) -> Execution:
|
|
65
|
+
status_key = self._id_to_key(execution_id, "status")
|
|
66
|
+
if await self._client.read(status_key) is None:
|
|
67
|
+
self._client.stage_write(status_key, ExecutionStatus.STARTED)
|
|
68
|
+
return _MemoryExecution(self, execution_id)
|
|
69
|
+
|
|
70
|
+
async def get_execution_record(
|
|
71
|
+
self, execution_id: ExecutionId, return_type: type
|
|
72
|
+
) -> ExecutionRecord | None:
|
|
73
|
+
status: ExecutionStatus | None = await self._client.read(
|
|
74
|
+
self._id_to_key(execution_id, "status")
|
|
75
|
+
)
|
|
76
|
+
if not status:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
result = None
|
|
80
|
+
error = None
|
|
81
|
+
|
|
82
|
+
if status == ExecutionStatus.COMPLETED:
|
|
83
|
+
serialized_value = await self._client.read(
|
|
84
|
+
self._id_to_key(execution_id, "result")
|
|
85
|
+
)
|
|
86
|
+
if serialized_value:
|
|
87
|
+
result = self._serializer.deserialize(serialized_value, return_type)
|
|
88
|
+
elif status == ExecutionStatus.FAILED:
|
|
89
|
+
error = await self._client.read(self._id_to_key(execution_id, "error"))
|
|
90
|
+
|
|
91
|
+
return ExecutionRecord(status=status, result=result, error=error)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MemoryClient:
|
|
8
|
+
"""A low-level in-memory data store with transactional capabilities."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self._data: dict[str, Any] = {}
|
|
12
|
+
self._staged_writes: dict[str, Any] = {}
|
|
13
|
+
self._staged_deletes: set[str] = set()
|
|
14
|
+
self._lock = asyncio.Lock()
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def data(self) -> dict[str, Any]:
|
|
18
|
+
return self._data
|
|
19
|
+
|
|
20
|
+
def clear_staged(self) -> None:
|
|
21
|
+
self._staged_writes.clear()
|
|
22
|
+
self._staged_deletes.clear()
|
|
23
|
+
|
|
24
|
+
async def commit_staged(self) -> None:
|
|
25
|
+
async with self._lock:
|
|
26
|
+
self._data.update(self._staged_writes)
|
|
27
|
+
for key in self._staged_deletes:
|
|
28
|
+
self._data.pop(key, None)
|
|
29
|
+
self.clear_staged()
|
|
30
|
+
|
|
31
|
+
async def read(self, key: str) -> Any | None:
|
|
32
|
+
async with self._lock:
|
|
33
|
+
return self._data.get(key)
|
|
34
|
+
|
|
35
|
+
def stage_write(self, key: str, value: Any) -> None:
|
|
36
|
+
self._staged_writes[key] = value
|
|
37
|
+
self._staged_deletes.discard(key)
|
|
38
|
+
|
|
39
|
+
def stage_delete(self, key: str) -> None:
|
|
40
|
+
self._staged_deletes.add(key)
|
|
41
|
+
self._staged_writes.pop(key, None)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glyff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Guaranteed Lightweight Yieldable Function Foundation for checkpointed and resumable task execution.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 nueruyu
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Classifier: Development Status :: 3 - Alpha
|
|
28
|
+
Classifier: Framework :: AsyncIO
|
|
29
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
30
|
+
Classifier: Operating System :: OS Independent
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
+
Classifier: Typing :: Typed
|
|
36
|
+
Requires-Python: >=3.11
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# glyff
|
|
40
|
+
|
|
41
|
+
**G**uaranteed **L**ightweight **Y**ieldable **F**unction **F**oundation.
|
|
42
|
+
|
|
43
|
+
A primitive for pausing async functions across process and request boundaries,
|
|
44
|
+
and resuming them later from the same point.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install glyff
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`glyff` has no dependencies beyond the Python standard library.
|
|
53
|
+
|
|
54
|
+
## Behavior
|
|
55
|
+
|
|
56
|
+
- Marked function calls are recorded in a session-scoped store, keyed by
|
|
57
|
+
function identity, arguments, and call position.
|
|
58
|
+
- Re-invoking the same call within the same session returns the recorded
|
|
59
|
+
result instead of re-executing.
|
|
60
|
+
- A call's outcome — success or failure — is permanent once recorded.
|
|
61
|
+
- `YieldException` suspends execution at a function boundary; the session
|
|
62
|
+
can be resumed later by entering it again with the same session id.
|
|
63
|
+
|
|
64
|
+
## Public API
|
|
65
|
+
|
|
66
|
+
| Name | Description |
|
|
67
|
+
| ----------------- | --------------------------------------------------------------- |
|
|
68
|
+
| `engrave` | Decorator that marks an async function for recording. |
|
|
69
|
+
| `Session` | Async context manager that scopes a sequence of engraved calls. |
|
|
70
|
+
| `ExecutionId` | Identifier for a recorded function execution. |
|
|
71
|
+
| `ExecutionRecord` | Persisted execution state and result. |
|
|
72
|
+
| `ExecutionStatus` | Enum: `STARTED`, `COMPLETED`, `FAILED`. |
|
|
73
|
+
| `SessionStore` | Protocol for storage backends. |
|
|
74
|
+
| `Serializer` | Protocol for value serialization. |
|
|
75
|
+
| `ArgsHasher` | Protocol for argument hashing. |
|
|
76
|
+
| `YieldException` | Raised to suspend a session. |
|
|
77
|
+
|
|
78
|
+
## Extending
|
|
79
|
+
|
|
80
|
+
- For persistent storage, see [`glyff-file-store`](https://pypi.org/project/glyff-file-store/).
|
|
81
|
+
- For Pydantic-typed serialization, see [`glyff-pydantic`](https://pypi.org/project/glyff-pydantic/).
|
|
82
|
+
- Custom backends can be written by implementing the `SessionStore`,
|
|
83
|
+
`Serializer`, and `ArgsHasher` protocols.
|
|
84
|
+
|
|
85
|
+
## Status
|
|
86
|
+
|
|
87
|
+
Early development. APIs may change before v1.0.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
glyff/__init__.py,sha256=Pe34jmNPe-42wd4F28DlViR1eG3egfmymKUolzmHBiA,354
|
|
2
|
+
glyff/context.py,sha256=zQafkvNDArFzViYNZEOK43VhcdpotfUj5a5qOCx18ww,5160
|
|
3
|
+
glyff/decorators.py,sha256=eOmSKdcmzCwGZ0homEylYXiGmDS1XCsLq5I9gMMFKhc,1609
|
|
4
|
+
glyff/exceptions.py,sha256=a60joZIVHrgaE9Sa9q8bPAzvBfyBOyY3Za2R2CNSYEM,403
|
|
5
|
+
glyff/executor.py,sha256=d2377qaFZlJJgMgezEPWCfcVsb_Jpig8AHOvmikV1gc,2021
|
|
6
|
+
glyff/interfaces.py,sha256=9c7oVYWyc7emVEWkRT7WNoqlFK334apKB6eVAQ7cgtU,2434
|
|
7
|
+
glyff/models.py,sha256=LQxkbYHpzuJtb90SFJ-gknauVMIHo2H4ieVg1L_LiXo,1219
|
|
8
|
+
glyff/sequencer.py,sha256=vruVm6XcAet3oVuI8NubekZ-snRxqW9pZ_3YKIsOc8w,1404
|
|
9
|
+
glyff/session.py,sha256=65i6DRRzGqa-1G-c7kWf4vDgYI_QAvoUTXg8T6I3bMo,1719
|
|
10
|
+
glyff/serialization/__init__.py,sha256=Bm1my_ChfxgzqWvBWWCuAZ9HVQVZk8Lu86dQYGdC3YQ,108
|
|
11
|
+
glyff/serialization/json.py,sha256=HPledSVYhXY9CJp8bWYLgDqDulflwVcBeXemYA34cKI,1585
|
|
12
|
+
glyff/stores/__init__.py,sha256=EMt0UPcq21XfA_f4hC2_ZeTZGmwvGxWWYPk-P5qrBtI,140
|
|
13
|
+
glyff/stores/memory.py,sha256=BPwlCL8IYeudd_Fcy9GTLIk29bYxYChuvjoQ8QEpa3c,3318
|
|
14
|
+
glyff/stores/memory_client.py,sha256=l-80FXLOaagZ3Rya4ePBA60N2topplx3c0xJbk4OYUs,1189
|
|
15
|
+
glyff-0.1.0.dist-info/METADATA,sha256=6HhB9vH6WNp6fpq53TZCZ2CnBxlhK-TIwDMsA_Og8II,4048
|
|
16
|
+
glyff-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
17
|
+
glyff-0.1.0.dist-info/licenses/LICENSE,sha256=bNtLcBZk03HGyB7qD0gK37uO4E0sre_G01X1vCFBPG8,1064
|
|
18
|
+
glyff-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nueruyu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|