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.
@@ -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