functualize-state-sqlite 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.
- functualize_state_sqlite/__init__.py +13 -0
- functualize_state_sqlite/_backend.py +160 -0
- functualize_state_sqlite/_execution_store.py +379 -0
- functualize_state_sqlite/_migrations.py +157 -0
- functualize_state_sqlite/_plugin.py +205 -0
- functualize_state_sqlite/plugin.py +345 -0
- functualize_state_sqlite/py.typed +0 -0
- functualize_state_sqlite/sqlite_backend.py +721 -0
- functualize_state_sqlite/state_store.py +178 -0
- functualize_state_sqlite/tracker.py +318 -0
- functualize_state_sqlite-0.1.0.dist-info/METADATA +88 -0
- functualize_state_sqlite-0.1.0.dist-info/RECORD +14 -0
- functualize_state_sqlite-0.1.0.dist-info/WHEEL +4 -0
- functualize_state_sqlite-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""SQLite State Plugin — DI registration and scope lifecycle integration.
|
|
2
|
+
|
|
3
|
+
Registers SQLiteStateBackend as StateBackend and SQLiteExecutionStore as
|
|
4
|
+
ExecutionStore with the DI registry via app.provide(). Hooks into
|
|
5
|
+
ON_SCOPE_CREATED to replace the scope's in-memory state with persistent
|
|
6
|
+
SQLite-backed storage.
|
|
7
|
+
|
|
8
|
+
Registered via entry point `functualize.state_providers` with name "sqlite".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from functualize_state import ExecutionStore, StateBackend
|
|
17
|
+
|
|
18
|
+
from functualize_state_sqlite._backend import SQLiteStateBackend
|
|
19
|
+
from functualize_state_sqlite._execution_store import SQLiteExecutionStore
|
|
20
|
+
from functualize_state_sqlite.sqlite_backend import SQLiteBackend
|
|
21
|
+
from functualize_state_sqlite.state_store import SQLiteStateStore
|
|
22
|
+
|
|
23
|
+
__all__ = ["SQLiteStatePlugin"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SQLiteStatePlugin:
|
|
29
|
+
"""Plugin that registers SQLite-backed StateBackend and ExecutionStore.
|
|
30
|
+
|
|
31
|
+
At boot time (APP_READY), creates SQLiteStateBackend and SQLiteExecutionStore
|
|
32
|
+
instances sharing the same database path, and registers them with the DI
|
|
33
|
+
registry via app.provide().
|
|
34
|
+
|
|
35
|
+
When a WorkflowScope is created, replaces its in-memory state store with
|
|
36
|
+
a persistent SQLiteStateStore backed by the shared SQLiteBackend.
|
|
37
|
+
|
|
38
|
+
Implements the plugin callable protocol expected by functualize's plugin
|
|
39
|
+
discovery system.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str = "sqlite-state"
|
|
43
|
+
version: str = "0.1.0"
|
|
44
|
+
description: str = "SQLite-backed StateBackend and ExecutionStore provider"
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._backend: SQLiteStateBackend | None = None
|
|
48
|
+
self._execution_store: SQLiteExecutionStore | None = None
|
|
49
|
+
self._scope_backend: SQLiteBackend | None = None
|
|
50
|
+
self._app: Any = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def backend(self) -> SQLiteStateBackend | None:
|
|
54
|
+
"""The SQLiteStateBackend instance (available after APP_READY)."""
|
|
55
|
+
return self._backend
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def execution_store(self) -> SQLiteExecutionStore | None:
|
|
59
|
+
"""The SQLiteExecutionStore instance (available after APP_READY)."""
|
|
60
|
+
return self._execution_store
|
|
61
|
+
|
|
62
|
+
def __call__(self, app: Any) -> None:
|
|
63
|
+
"""Register the plugin with the application instance.
|
|
64
|
+
|
|
65
|
+
Hooks into APP_READY for initialization and DI registration,
|
|
66
|
+
and ON_SCOPE_CREATED for scope state replacement.
|
|
67
|
+
"""
|
|
68
|
+
self._app = app
|
|
69
|
+
hook_registry = app.hook_registry
|
|
70
|
+
|
|
71
|
+
from functualize._events.hooks import HookEvent
|
|
72
|
+
|
|
73
|
+
# APP_READY: initialize backend, execution store, and register with DI
|
|
74
|
+
hook_registry.register_global(HookEvent.APP_READY, self._on_app_ready)
|
|
75
|
+
|
|
76
|
+
# ON_SCOPE_CREATED: replace scope state with SQLite-backed store
|
|
77
|
+
hook_registry.register_global(
|
|
78
|
+
HookEvent.ON_SCOPE_CREATED, self._on_scope_created
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def on_shutdown(self, app: Any) -> None:
|
|
82
|
+
"""Close database connections on application shutdown."""
|
|
83
|
+
if self._backend is not None:
|
|
84
|
+
try:
|
|
85
|
+
self._backend.close()
|
|
86
|
+
logger.debug("SQLiteStatePlugin: SQLiteStateBackend closed.")
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error("SQLiteStatePlugin: Error closing backend: %s", e)
|
|
89
|
+
finally:
|
|
90
|
+
self._backend = None
|
|
91
|
+
|
|
92
|
+
if self._execution_store is not None:
|
|
93
|
+
try:
|
|
94
|
+
self._execution_store.close()
|
|
95
|
+
logger.debug("SQLiteStatePlugin: SQLiteExecutionStore closed.")
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error("SQLiteStatePlugin: Error closing execution store: %s", e)
|
|
98
|
+
finally:
|
|
99
|
+
self._execution_store = None
|
|
100
|
+
|
|
101
|
+
if self._scope_backend is not None:
|
|
102
|
+
try:
|
|
103
|
+
self._scope_backend.close()
|
|
104
|
+
logger.debug("SQLiteStatePlugin: scope SQLiteBackend closed.")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.error("SQLiteStatePlugin: Error closing scope backend: %s", e)
|
|
107
|
+
finally:
|
|
108
|
+
self._scope_backend = None
|
|
109
|
+
|
|
110
|
+
# ─── Hook Handlers ────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def _on_app_ready(self, app: Any) -> None:
|
|
113
|
+
"""Initialize SQLite instances and register with DI registry.
|
|
114
|
+
|
|
115
|
+
Creates SQLiteStateBackend and SQLiteExecutionStore sharing the same
|
|
116
|
+
database path, runs schema migrations, and registers both with the
|
|
117
|
+
app's DI registry as their respective protocol types.
|
|
118
|
+
|
|
119
|
+
Also initializes the scope-level SQLiteBackend (old-style) for use
|
|
120
|
+
in ON_SCOPE_CREATED to replace scope state stores.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
# Resolve db_path from config if available
|
|
124
|
+
db_path = self._resolve_db_path(app)
|
|
125
|
+
|
|
126
|
+
# Initialize the scope-level backend (old-style) first.
|
|
127
|
+
# This uses the composite-key state table (scope_id, job_namespace, key)
|
|
128
|
+
# which is the format SQLiteStateStore expects.
|
|
129
|
+
# It owns the primary database file.
|
|
130
|
+
scope_db_path = db_path
|
|
131
|
+
self._scope_backend = SQLiteBackend(db_path=scope_db_path)
|
|
132
|
+
self._scope_backend.initialize()
|
|
133
|
+
|
|
134
|
+
# Create the protocol-conforming StateBackend and ExecutionStore.
|
|
135
|
+
# These share the same database as the scope backend. The
|
|
136
|
+
# SQLiteStateBackend uses a separate table name to avoid conflicts
|
|
137
|
+
# with the old-style composite-key state table.
|
|
138
|
+
self._backend = SQLiteStateBackend(db_path=str(self._scope_backend.db_path))
|
|
139
|
+
self._execution_store = SQLiteExecutionStore(
|
|
140
|
+
db_path=str(self._scope_backend.db_path)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Run schema migrations
|
|
144
|
+
from functualize_state_sqlite._migrations import migrate
|
|
145
|
+
|
|
146
|
+
migrate(self._scope_backend.connection)
|
|
147
|
+
|
|
148
|
+
# Register with DI registry via app.provide()
|
|
149
|
+
app.provide(StateBackend, self._backend)
|
|
150
|
+
app.provide(ExecutionStore, self._execution_store)
|
|
151
|
+
|
|
152
|
+
logger.debug(
|
|
153
|
+
"SQLiteStatePlugin: Registered StateBackend and ExecutionStore (db=%s)",
|
|
154
|
+
self._scope_backend.db_path,
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error("SQLiteStatePlugin: Failed to initialize: %s", e)
|
|
158
|
+
|
|
159
|
+
def _on_scope_created(self, scope: Any) -> None:
|
|
160
|
+
"""Replace the scope's in-memory state store with SQLite-backed state.
|
|
161
|
+
|
|
162
|
+
Creates a SQLiteStateStore scoped to the workflow scope's ID, backed
|
|
163
|
+
by the shared SQLiteBackend (old-style) that uses the composite-key
|
|
164
|
+
state table for proper namespace isolation per scope.
|
|
165
|
+
"""
|
|
166
|
+
if self._scope_backend is None:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
scope_id = scope.scope_id if hasattr(scope, "scope_id") else str(id(scope))
|
|
171
|
+
|
|
172
|
+
sqlite_store = SQLiteStateStore(
|
|
173
|
+
backend=self._scope_backend,
|
|
174
|
+
scope_id=scope_id,
|
|
175
|
+
job_namespace="__scope__",
|
|
176
|
+
)
|
|
177
|
+
scope.replace_state_store(sqlite_store)
|
|
178
|
+
logger.debug(
|
|
179
|
+
"SQLiteStatePlugin: Replaced state store for scope '%s'",
|
|
180
|
+
scope_id,
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.error("SQLiteStatePlugin: Error in ON_SCOPE_CREATED handler: %s", e)
|
|
184
|
+
|
|
185
|
+
# ─── Internal Helpers ─────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _resolve_db_path(self, app: Any) -> str | None:
|
|
188
|
+
"""Resolve database path from app configuration.
|
|
189
|
+
|
|
190
|
+
Returns None to use the default path if no configuration is found.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
from pydantic import BaseModel, Field
|
|
194
|
+
|
|
195
|
+
class _SqliteConfig(BaseModel):
|
|
196
|
+
db_path: str | None = Field(
|
|
197
|
+
default=None,
|
|
198
|
+
description="Path to the SQLite database file.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
config = app.resolve_model("plugin.sqlite-state", _SqliteConfig)
|
|
202
|
+
return config.db_path
|
|
203
|
+
except Exception:
|
|
204
|
+
# No config available — use default path
|
|
205
|
+
return None
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Execution State Plugin — entry point integrating with functualize lifecycle hooks.
|
|
2
|
+
|
|
3
|
+
Hooks into APP_READY, BEFORE_JOB, AFTER_SUCCESS, AFTER_FAILURE, ON_TEARDOWN,
|
|
4
|
+
INVOKE_START, INVOKE_END, and ON_SCOPE_CREATED to persist execution history
|
|
5
|
+
and provide SQLite-backed state storage.
|
|
6
|
+
|
|
7
|
+
Implements PluginConfigProtocol (name, version, description, __call__) and
|
|
8
|
+
PluginWithShutdown (on_shutdown) for graceful resource cleanup.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from functualize_state_sqlite.sqlite_backend import SQLiteBackend
|
|
20
|
+
from functualize_state_sqlite.state_store import SQLiteStateStore
|
|
21
|
+
from functualize_state_sqlite.tracker import ExecutionTracker
|
|
22
|
+
|
|
23
|
+
__all__ = ["ExecutionStatePlugin"]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExecutionStateConfig(BaseModel):
|
|
29
|
+
"""Configuration for the execution state plugin."""
|
|
30
|
+
|
|
31
|
+
db_path: str | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
description="Path to the SQLite database file. Defaults to .functualize/execution.db",
|
|
34
|
+
)
|
|
35
|
+
session_ttl: float = Field(
|
|
36
|
+
default=1800.0,
|
|
37
|
+
description="Session TTL in seconds before a new session is created (default 30min)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ExecutionStatePlugin:
|
|
42
|
+
"""SQLite-backed execution tracking and persistent state plugin.
|
|
43
|
+
|
|
44
|
+
Registers lifecycle hooks to automatically track job executions,
|
|
45
|
+
persist state across process restarts, and provide AI context summaries.
|
|
46
|
+
|
|
47
|
+
Implements PluginConfigProtocol + PluginWithShutdown.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
name: str = "execution-state"
|
|
51
|
+
version: str = "0.1.0"
|
|
52
|
+
description: str = "SQLite-backed execution tracking and persistent state"
|
|
53
|
+
config_model = ExecutionStateConfig
|
|
54
|
+
config_section: str = "plugin.execution-state"
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self._backend: SQLiteBackend | None = None
|
|
58
|
+
self._tracker: ExecutionTracker | None = None
|
|
59
|
+
self._app: Any = None
|
|
60
|
+
# Track parent_uid for nested invocations via INVOKE_START/INVOKE_END
|
|
61
|
+
self._invoke_parent_stack: list[str] = []
|
|
62
|
+
# Maps rc id -> execution_uid for retrieval in AFTER hooks
|
|
63
|
+
self._rc_execution_map: dict[int, str] = {}
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def backend(self) -> SQLiteBackend | None:
|
|
67
|
+
"""The SQLiteBackend instance (available after APP_READY)."""
|
|
68
|
+
return self._backend
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def tracker(self) -> ExecutionTracker | None:
|
|
72
|
+
"""The ExecutionTracker instance (available after APP_READY)."""
|
|
73
|
+
return self._tracker
|
|
74
|
+
|
|
75
|
+
def __call__(self, app: Any) -> None:
|
|
76
|
+
"""Register the plugin with the application instance.
|
|
77
|
+
|
|
78
|
+
Hooks into: APP_READY, BEFORE_JOB, AFTER_SUCCESS, AFTER_FAILURE,
|
|
79
|
+
ON_TEARDOWN, INVOKE_START, INVOKE_END, ON_SCOPE_CREATED.
|
|
80
|
+
"""
|
|
81
|
+
self._app = app
|
|
82
|
+
hook_registry = app.hook_registry
|
|
83
|
+
|
|
84
|
+
from functualize._events.hooks import HookEvent
|
|
85
|
+
|
|
86
|
+
# APP_READY: initialize DB
|
|
87
|
+
hook_registry.register_global(HookEvent.APP_READY, self._on_app_ready)
|
|
88
|
+
|
|
89
|
+
# BEFORE_JOB: record execution start
|
|
90
|
+
hook_registry.register_global(HookEvent.BEFORE_JOB, self._on_before_job)
|
|
91
|
+
|
|
92
|
+
# AFTER_SUCCESS: record successful completion
|
|
93
|
+
hook_registry.register_global(HookEvent.AFTER_SUCCESS, self._on_after_success)
|
|
94
|
+
|
|
95
|
+
# AFTER_FAILURE: record failed completion
|
|
96
|
+
hook_registry.register_global(HookEvent.AFTER_FAILURE, self._on_after_failure)
|
|
97
|
+
|
|
98
|
+
# ON_TEARDOWN: finalize execution (no-op currently, reserved for future)
|
|
99
|
+
hook_registry.register_global(HookEvent.ON_TEARDOWN, self._on_teardown)
|
|
100
|
+
|
|
101
|
+
# INVOKE_START / INVOKE_END: track nested invocations
|
|
102
|
+
hook_registry.register_global(HookEvent.INVOKE_START, self._on_invoke_start)
|
|
103
|
+
hook_registry.register_global(HookEvent.INVOKE_END, self._on_invoke_end)
|
|
104
|
+
|
|
105
|
+
# ON_SCOPE_CREATED: replace state store with SQLiteStateStore
|
|
106
|
+
hook_registry.register_global(
|
|
107
|
+
HookEvent.ON_SCOPE_CREATED, self._on_scope_created
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def on_shutdown(self, app: Any) -> None:
|
|
111
|
+
"""Close the SQLiteBackend on application shutdown."""
|
|
112
|
+
if self._backend is not None:
|
|
113
|
+
try:
|
|
114
|
+
self._backend.close()
|
|
115
|
+
logger.debug("ExecutionStatePlugin: SQLiteBackend closed.")
|
|
116
|
+
except Exception as e:
|
|
117
|
+
logger.error("ExecutionStatePlugin: Error closing backend: %s", e)
|
|
118
|
+
finally:
|
|
119
|
+
self._backend = None
|
|
120
|
+
self._tracker = None
|
|
121
|
+
|
|
122
|
+
# ─── Hook Handlers ────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _on_app_ready(self, app: Any) -> None:
|
|
125
|
+
"""Initialize the SQLiteBackend and ExecutionTracker on app boot."""
|
|
126
|
+
try:
|
|
127
|
+
db_path = None
|
|
128
|
+
# Try to resolve config if available
|
|
129
|
+
try:
|
|
130
|
+
config = app.resolve_model(self.config_section, self.config_model)
|
|
131
|
+
if config.db_path:
|
|
132
|
+
db_path = config.db_path
|
|
133
|
+
session_ttl = config.session_ttl
|
|
134
|
+
except Exception:
|
|
135
|
+
# Config resolution may fail if no config file exists
|
|
136
|
+
session_ttl = 1800.0
|
|
137
|
+
|
|
138
|
+
self._backend = SQLiteBackend(db_path=db_path)
|
|
139
|
+
self._backend.initialize()
|
|
140
|
+
|
|
141
|
+
self._tracker = ExecutionTracker(
|
|
142
|
+
self._backend,
|
|
143
|
+
session_ttl=session_ttl,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
logger.debug(
|
|
147
|
+
"ExecutionStatePlugin initialized (db=%s)",
|
|
148
|
+
self._backend.db_path,
|
|
149
|
+
)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.error("ExecutionStatePlugin: Failed to initialize backend: %s", e)
|
|
152
|
+
|
|
153
|
+
def _on_before_job(self, rc: Any, **hook_kwargs: Any) -> None:
|
|
154
|
+
"""Record execution start and attach execution_uid to rc metadata."""
|
|
155
|
+
if self._tracker is None:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
job_name = rc.name
|
|
160
|
+
|
|
161
|
+
# Determine parent_uid from the invoke parent stack.
|
|
162
|
+
# When a parent invokes a child, INVOKE_START pushes the parent's
|
|
163
|
+
# execution_uid onto the stack. Then the child's BEFORE_JOB fires,
|
|
164
|
+
# and we read the top of that stack as the parent_uid.
|
|
165
|
+
parent_uid: str | None = None
|
|
166
|
+
depth = 0
|
|
167
|
+
if self._invoke_parent_stack:
|
|
168
|
+
parent_uid = self._invoke_parent_stack[-1]
|
|
169
|
+
depth = len(self._invoke_parent_stack)
|
|
170
|
+
|
|
171
|
+
# Serialize kwargs if provided
|
|
172
|
+
kwargs_json: str | None = None
|
|
173
|
+
raw_kwargs = hook_kwargs.get("kwargs")
|
|
174
|
+
if raw_kwargs is not None:
|
|
175
|
+
kwargs_json = _safe_serialize(raw_kwargs)
|
|
176
|
+
|
|
177
|
+
# Record the start
|
|
178
|
+
execution_uid = self._tracker.record_start(
|
|
179
|
+
job_name,
|
|
180
|
+
kwargs_json=kwargs_json,
|
|
181
|
+
parent_uid=parent_uid,
|
|
182
|
+
depth=depth,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Attach execution_uid to RunContext result metadata
|
|
186
|
+
rc.set_result_metadata("execution_uid", execution_uid)
|
|
187
|
+
|
|
188
|
+
# Store execution_uid for retrieval in AFTER hooks
|
|
189
|
+
self._rc_execution_map[id(rc)] = execution_uid
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error("ExecutionStatePlugin: Error in BEFORE_JOB handler: %s", e)
|
|
193
|
+
|
|
194
|
+
def _on_after_success(self, rc: Any, **hook_kwargs: Any) -> None:
|
|
195
|
+
"""Record successful execution end."""
|
|
196
|
+
if self._tracker is None:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
execution_uid = self._get_execution_uid(rc)
|
|
201
|
+
if execution_uid is None:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
result = hook_kwargs.get("result")
|
|
205
|
+
result_json = _safe_serialize(result)
|
|
206
|
+
|
|
207
|
+
# Calculate duration from rc if available
|
|
208
|
+
duration_ms = _get_duration_ms(rc)
|
|
209
|
+
|
|
210
|
+
self._tracker.record_end(
|
|
211
|
+
execution_uid,
|
|
212
|
+
status="success",
|
|
213
|
+
duration_ms=duration_ms,
|
|
214
|
+
result_json=result_json,
|
|
215
|
+
)
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error("ExecutionStatePlugin: Error in AFTER_SUCCESS handler: %s", e)
|
|
218
|
+
|
|
219
|
+
def _on_after_failure(self, rc: Any, exception: Exception | None = None) -> None:
|
|
220
|
+
"""Record failed execution end."""
|
|
221
|
+
if self._tracker is None:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
execution_uid = self._get_execution_uid(rc)
|
|
226
|
+
if execution_uid is None:
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
error_message: str | None = None
|
|
230
|
+
error_type: str | None = None
|
|
231
|
+
if exception is not None:
|
|
232
|
+
error_message = str(exception)
|
|
233
|
+
error_type = type(exception).__name__
|
|
234
|
+
|
|
235
|
+
duration_ms = _get_duration_ms(rc)
|
|
236
|
+
|
|
237
|
+
self._tracker.record_end(
|
|
238
|
+
execution_uid,
|
|
239
|
+
status="failure",
|
|
240
|
+
duration_ms=duration_ms,
|
|
241
|
+
error_message=error_message,
|
|
242
|
+
error_type=error_type,
|
|
243
|
+
)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error("ExecutionStatePlugin: Error in AFTER_FAILURE handler: %s", e)
|
|
246
|
+
|
|
247
|
+
def _on_teardown(self, rc: Any) -> None:
|
|
248
|
+
"""Finalize execution tracking for this run context.
|
|
249
|
+
|
|
250
|
+
Cleans up the rc-to-execution mapping to prevent memory leaks.
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
rc_id = id(rc)
|
|
254
|
+
self._rc_execution_map.pop(rc_id, None)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error("ExecutionStatePlugin: Error in ON_TEARDOWN handler: %s", e)
|
|
257
|
+
|
|
258
|
+
def _on_invoke_start(
|
|
259
|
+
self, rc: Any, child_name: str, kwargs: dict[str, Any], depth: int
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Track nested invocation start by pushing parent_uid onto the stack.
|
|
262
|
+
|
|
263
|
+
When a parent job invokes a child, we push the parent's execution_uid
|
|
264
|
+
so the child's BEFORE_JOB handler can link via parent_uid.
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
parent_execution_uid = self._get_execution_uid(rc)
|
|
268
|
+
if parent_execution_uid is None:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
self._invoke_parent_stack.append(parent_execution_uid)
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error("ExecutionStatePlugin: Error in INVOKE_START handler: %s", e)
|
|
275
|
+
|
|
276
|
+
def _on_invoke_end(self, rc: Any, child_name: str, depth: int, result: Any) -> None:
|
|
277
|
+
"""Track nested invocation end by popping from the parent stack."""
|
|
278
|
+
try:
|
|
279
|
+
if self._invoke_parent_stack:
|
|
280
|
+
self._invoke_parent_stack.pop()
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.error("ExecutionStatePlugin: Error in INVOKE_END handler: %s", e)
|
|
283
|
+
|
|
284
|
+
def _on_scope_created(self, scope: Any) -> None:
|
|
285
|
+
"""Replace the scope's state store with a SQLiteStateStore instance."""
|
|
286
|
+
if self._backend is None:
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
scope_id = scope.scope_id if hasattr(scope, "scope_id") else str(id(scope))
|
|
291
|
+
# Create a SQLiteStateStore scoped to this workflow scope
|
|
292
|
+
# Use a generic namespace that can be refined per-job
|
|
293
|
+
sqlite_store = SQLiteStateStore(
|
|
294
|
+
backend=self._backend,
|
|
295
|
+
scope_id=scope_id,
|
|
296
|
+
job_namespace="__scope__",
|
|
297
|
+
)
|
|
298
|
+
scope.replace_state_store(sqlite_store)
|
|
299
|
+
logger.debug(
|
|
300
|
+
"ExecutionStatePlugin: Replaced state store for scope '%s'",
|
|
301
|
+
scope_id,
|
|
302
|
+
)
|
|
303
|
+
except Exception as e:
|
|
304
|
+
logger.error(
|
|
305
|
+
"ExecutionStatePlugin: Error in ON_SCOPE_CREATED handler: %s", e
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# ─── Internal Helpers ─────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def _get_execution_uid(self, rc: Any) -> str | None:
|
|
311
|
+
"""Retrieve the execution_uid associated with a RunContext."""
|
|
312
|
+
return self._rc_execution_map.get(id(rc))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ─── Module-Level Helpers ─────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _safe_serialize(value: Any) -> str | None:
|
|
319
|
+
"""Serialize a value to JSON, returning None for non-serializable values."""
|
|
320
|
+
if value is None:
|
|
321
|
+
return None
|
|
322
|
+
try:
|
|
323
|
+
return json.dumps(value)
|
|
324
|
+
except (TypeError, ValueError, OverflowError):
|
|
325
|
+
type_name = type(value).__name__
|
|
326
|
+
return json.dumps(f"<non-serializable: {type_name}>")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _get_duration_ms(rc: Any) -> float:
|
|
330
|
+
"""Extract duration_ms from a RunContext if available."""
|
|
331
|
+
try:
|
|
332
|
+
if hasattr(rc, "duration_ms"):
|
|
333
|
+
val = rc.duration_ms
|
|
334
|
+
if isinstance(val, int | float):
|
|
335
|
+
return float(val)
|
|
336
|
+
if hasattr(rc, "_start_time"):
|
|
337
|
+
import time
|
|
338
|
+
|
|
339
|
+
start = rc._start_time
|
|
340
|
+
if isinstance(start, int | float):
|
|
341
|
+
elapsed = time.perf_counter() - start
|
|
342
|
+
return elapsed * 1000
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
return 0.0
|
|
File without changes
|