functualize-state-sqlite 0.2.3__tar.gz → 0.3.0__tar.gz
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-0.2.3 → functualize_state_sqlite-0.3.0}/.gitignore +27 -0
- functualize_state_sqlite-0.3.0/LICENSE +202 -0
- functualize_state_sqlite-0.3.0/NOTICE +5 -0
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/PKG-INFO +5 -4
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/README.md +1 -1
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/pyproject.toml +2 -4
- functualize_state_sqlite-0.3.0/src/functualize_state_sqlite/__init__.py +14 -0
- functualize_state_sqlite-0.3.0/src/functualize_state_sqlite/_plugin.py +113 -0
- functualize_state_sqlite-0.3.0/src/functualize_state_sqlite/substrate.py +225 -0
- functualize_state_sqlite-0.3.0/tests/test_sqlite_substrate.py +355 -0
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/__init__.py +0 -13
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_backend.py +0 -160
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_execution_store.py +0 -379
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_migrations.py +0 -157
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/_plugin.py +0 -205
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/plugin.py +0 -345
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/sqlite_backend.py +0 -721
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/state_store.py +0 -178
- functualize_state_sqlite-0.2.3/src/functualize_state_sqlite/tracker.py +0 -318
- functualize_state_sqlite-0.2.3/tests/test_execution_state_integration.py +0 -638
- functualize_state_sqlite-0.2.3/tests/test_plugin.py +0 -86
- functualize_state_sqlite-0.2.3/tests/test_sqlite_backend.py +0 -437
- functualize_state_sqlite-0.2.3/tests/test_sqlite_state_store_properties.py +0 -365
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/examples/README.md +0 -0
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/examples/persistent_counter/persistent_counter.py +0 -0
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/examples/persistent_counter/test_persistent_counter.py +0 -0
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/src/functualize_state_sqlite/py.typed +0 -0
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/tests/__init__.py +0 -0
- {functualize_state_sqlite-0.2.3 → functualize_state_sqlite-0.3.0}/tests/conftest.py +0 -0
|
@@ -113,3 +113,30 @@ examples/plugins/file_based_plugin/.functualize/plugins/*
|
|
|
113
113
|
.release/
|
|
114
114
|
.worktrees/
|
|
115
115
|
.claude/settings.local.json
|
|
116
|
+
|
|
117
|
+
# Agent index directories. Which parts are tracked is decided by ONE property:
|
|
118
|
+
# whether the stored paths are portable.
|
|
119
|
+
#
|
|
120
|
+
# graphify graph.json holds only repo-relative paths, so it travels into every
|
|
121
|
+
# worktree and every ephemeral cloud checkout for free. Tracked.
|
|
122
|
+
# Its cache/, manifest.json and .graphify_root are machine-local
|
|
123
|
+
# (mtimes, absolute paths), so the rule below is a whitelist: ignore
|
|
124
|
+
# everything, then un-ignore the two portable artifacts.
|
|
125
|
+
# zvec-grep index entries are keyed by ABSOLUTE path — copying one and rewriting
|
|
126
|
+
# its manifest yields 0% coverage and a full re-embed. Never tracked;
|
|
127
|
+
# each workspace builds its own (~21s scoped).
|
|
128
|
+
# serena cache/ pickles hold absolute file:// URIs, so those and
|
|
129
|
+
# project.local.yml are machine-local; serena's own
|
|
130
|
+
# .serena/.gitignore already excludes cache/ and project.local.yml.
|
|
131
|
+
# memories/ is plain text and portable, but not tracked anyway: it
|
|
132
|
+
# duplicated contract docs that already have a committed home
|
|
133
|
+
# (`.claude/rules/spec-workflow.md`, `.spec/ARCHITECTURE.md`,
|
|
134
|
+
# AGENTS.md) and went stale the moment those changed underneath
|
|
135
|
+
# it. project.yml stays tracked and portable.
|
|
136
|
+
graphify-out/*
|
|
137
|
+
!graphify-out/graph.json
|
|
138
|
+
!graphify-out/GRAPH_REPORT.md
|
|
139
|
+
.zvec-grep/
|
|
140
|
+
.serena/cache/
|
|
141
|
+
.serena/project.local.yml
|
|
142
|
+
.serena/memories/
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: functualize-state-sqlite
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: SQLite-backed state persistence and execution tracking plugin for functualize
|
|
5
5
|
Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
|
|
6
|
-
License-Expression:
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
License-File: NOTICE
|
|
7
9
|
Classifier: Development Status :: 3 - Alpha
|
|
8
10
|
Classifier: Programming Language :: Python :: 3
|
|
9
11
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -11,7 +13,6 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
11
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
12
14
|
Classifier: Typing :: Typed
|
|
13
15
|
Requires-Python: >=3.11
|
|
14
|
-
Requires-Dist: functualize-state<1.0.0,>=0.1.0
|
|
15
16
|
Requires-Dist: functualize<1.0.0,>=0.1.0
|
|
16
17
|
Provides-Extra: dev
|
|
17
18
|
Requires-Dist: hypothesis>=6.82.0; extra == 'dev'
|
|
@@ -70,7 +71,7 @@ Public classes exported by this plugin:
|
|
|
70
71
|
Internal classes (available via direct import but not part of the public protocol surface):
|
|
71
72
|
|
|
72
73
|
- `SQLiteBackend` — Low-level connection manager with WAL mode, schema initialization, and query helpers for sessions, executions, steps, and namespaced state.
|
|
73
|
-
- `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()
|
|
74
|
+
- `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()` and `clear()`.
|
|
74
75
|
- `ExecutionTracker` — High-level session management and execution recording with automatic session resume based on TTL, and AI context summary generation via `to_ai_context()`.
|
|
75
76
|
|
|
76
77
|
## Development
|
|
@@ -49,7 +49,7 @@ Public classes exported by this plugin:
|
|
|
49
49
|
Internal classes (available via direct import but not part of the public protocol surface):
|
|
50
50
|
|
|
51
51
|
- `SQLiteBackend` — Low-level connection manager with WAL mode, schema initialization, and query helpers for sessions, executions, steps, and namespaced state.
|
|
52
|
-
- `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()
|
|
52
|
+
- `SQLiteStateStore` — `StateStoreProtocol` implementation scoped to a `(scope_id, job_namespace)` pair, with `get()`, `set()`, `delete()`, `keys()`, `to_dict()` and `clear()`.
|
|
53
53
|
- `ExecutionTracker` — High-level session management and execution recording with automatic session resume based on TTL, and AI context summary generation via `to_ai_context()`.
|
|
54
54
|
|
|
55
55
|
## Development
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "functualize-state-sqlite"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "SQLite-backed state persistence and execution tracking plugin for functualize"
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
license = "
|
|
6
|
+
license = "Apache-2.0"
|
|
7
7
|
authors = [
|
|
8
8
|
{ name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
|
|
9
9
|
]
|
|
@@ -18,7 +18,6 @@ classifiers = [
|
|
|
18
18
|
]
|
|
19
19
|
dependencies = [
|
|
20
20
|
"functualize>=0.1.0,<1.0.0",
|
|
21
|
-
"functualize-state>=0.1.0,<1.0.0",
|
|
22
21
|
]
|
|
23
22
|
|
|
24
23
|
[project.entry-points."functualize.state_providers"]
|
|
@@ -37,7 +36,6 @@ build-backend = "hatchling.build"
|
|
|
37
36
|
|
|
38
37
|
[tool.uv.sources]
|
|
39
38
|
functualize = { workspace = true }
|
|
40
|
-
functualize-state = { workspace = true }
|
|
41
39
|
|
|
42
40
|
[tool.hatch.build.targets.wheel]
|
|
43
41
|
packages = ["src/functualize_state_sqlite"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""SQLite-backed runtime state for functualize.
|
|
2
|
+
|
|
3
|
+
One substrate, installed at boot. See `substrate.py` for why this replaced a
|
|
4
|
+
key-value `StateBackend`, and `contributor/adr/022` for why that idea is
|
|
5
|
+
retired rather than deferred.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functualize_state_sqlite._plugin import SQLiteStatePlugin
|
|
9
|
+
from functualize_state_sqlite.substrate import SQLiteSubstrate
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SQLiteStatePlugin",
|
|
13
|
+
"SQLiteSubstrate",
|
|
14
|
+
]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The plugin: install a SQLite substrate, and nothing else.
|
|
2
|
+
|
|
3
|
+
`store-substrate`/T5, T6.
|
|
4
|
+
|
|
5
|
+
This used to be three things at once — a `StateBackend`, an `ExecutionStore`,
|
|
6
|
+
and a per-scope key-value store swapped in at `ON_SCOPE_CREATED`. All three are
|
|
7
|
+
gone, and the reason is the same one in all three cases: they were a *second*
|
|
8
|
+
storage vocabulary sitting beside the framework's own.
|
|
9
|
+
|
|
10
|
+
- The scope store swap happened one level below the real seam, so a scope could
|
|
11
|
+
keep its job state in SQLite while the records describing it stayed on the
|
|
12
|
+
filesystem. A resumed run then found its steps and not its variables.
|
|
13
|
+
- `StateBackend` and `ExecutionStore` were a backend-agnostic key-value
|
|
14
|
+
protocol, which can only offer the intersection of every backend — worth
|
|
15
|
+
least exactly where having a database is worth most. `contributor/adr/022`
|
|
16
|
+
records that argument so it is not re-proposed.
|
|
17
|
+
|
|
18
|
+
What is left is one line of work: choose the substrate. Every store follows,
|
|
19
|
+
because there is one place that decides and one object handed to all of them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from functualize_state_sqlite.substrate import SQLiteSubstrate
|
|
29
|
+
|
|
30
|
+
__all__ = ["SQLiteStatePlugin"]
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
#: Where the database goes when nothing configures it. Beside the project's
|
|
35
|
+
#: other runtime state, so `func builtin data clear` and a `.gitignore` that
|
|
36
|
+
#: already covers `.functualize/` keep working.
|
|
37
|
+
DEFAULT_DB_NAME = "state.db"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SQLiteStatePlugin:
|
|
41
|
+
"""Installs a :class:`SQLiteSubstrate` as the app's substrate at boot."""
|
|
42
|
+
|
|
43
|
+
name: str = "sqlite-state"
|
|
44
|
+
version: str = "0.2.0"
|
|
45
|
+
description: str = "Keeps this project's runtime state in SQLite"
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._substrate: SQLiteSubstrate | None = None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def substrate(self) -> SQLiteSubstrate | None:
|
|
52
|
+
"""The substrate this plugin installed, or None before APP_READY."""
|
|
53
|
+
return self._substrate
|
|
54
|
+
|
|
55
|
+
def __call__(self, app: Any) -> None:
|
|
56
|
+
from functualize._events.hooks import HookEvent
|
|
57
|
+
|
|
58
|
+
app.hook_registry.register_global(HookEvent.APP_READY, self._on_app_ready)
|
|
59
|
+
|
|
60
|
+
def _on_app_ready(self, app: Any) -> None:
|
|
61
|
+
"""Choose the substrate, once, before anything has resolved one.
|
|
62
|
+
|
|
63
|
+
`APP_READY` is the right moment and not an arbitrary one: the engine
|
|
64
|
+
resolves its substrate lazily, on the first store access, which happens
|
|
65
|
+
during a run. Installing later is **refused** by the app rather than
|
|
66
|
+
allowed to half-apply — some of a run's documents in one backend and
|
|
67
|
+
some in the other is exactly the state this feature exists to make
|
|
68
|
+
unreachable.
|
|
69
|
+
|
|
70
|
+
A failure to install is logged and left alone. The app then uses the
|
|
71
|
+
filesystem default, which is a working program with a note in the log
|
|
72
|
+
rather than a boot that dies over a storage preference.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
self._substrate = SQLiteSubstrate(self._db_path(app))
|
|
76
|
+
app.substrate = self._substrate
|
|
77
|
+
except Exception:
|
|
78
|
+
logger.exception(
|
|
79
|
+
"sqlite-state could not install its substrate; this project "
|
|
80
|
+
"will use the filesystem default"
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
logger.debug("sqlite-state installed a substrate at %s", self._substrate.path)
|
|
84
|
+
|
|
85
|
+
def _db_path(self, app: Any) -> Path:
|
|
86
|
+
"""``plugin.sqlite-state.db_path``, or beside the project's other state.
|
|
87
|
+
|
|
88
|
+
Resolved from :attr:`fresh_root` rather than the cwd, so a later
|
|
89
|
+
``chdir`` cannot move a run's database out from under it — the same
|
|
90
|
+
rule the filesystem substrate follows.
|
|
91
|
+
"""
|
|
92
|
+
configured = self._configured_path(app)
|
|
93
|
+
if configured:
|
|
94
|
+
return Path(configured)
|
|
95
|
+
return Path(app.fresh_root) / ".functualize" / DEFAULT_DB_NAME
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _configured_path(app: Any) -> str | None:
|
|
99
|
+
try:
|
|
100
|
+
from pydantic import BaseModel, Field
|
|
101
|
+
|
|
102
|
+
class _SqliteConfig(BaseModel):
|
|
103
|
+
db_path: str | None = Field(
|
|
104
|
+
default=None,
|
|
105
|
+
description="Where this project's SQLite state lives.",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
resolved = app.configuration.resolve_model(
|
|
109
|
+
"plugin.sqlite-state", _SqliteConfig
|
|
110
|
+
)
|
|
111
|
+
return resolved.db_path
|
|
112
|
+
except Exception:
|
|
113
|
+
return None
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""A `StoreSubstrate` over SQLite. One table, six methods.
|
|
2
|
+
|
|
3
|
+
`store-substrate`/T5.
|
|
4
|
+
|
|
5
|
+
This plugin used to supply a **key-value store for one scope's job state**,
|
|
6
|
+
swapped in through `WorkflowScope.replace_state_store`. That was a seam one
|
|
7
|
+
level below the real one, and it is what made the split brain reachable: a
|
|
8
|
+
scope could keep its job state in SQLite while the *records* describing that
|
|
9
|
+
scope — which steps ran, where the walk stopped, what a human approved — stayed
|
|
10
|
+
on the filesystem. A resumed run then found its steps and not its variables.
|
|
11
|
+
|
|
12
|
+
So the plugin now supplies a **substrate**, and every store moves together or
|
|
13
|
+
none does.
|
|
14
|
+
|
|
15
|
+
## Why this is the interesting implementation
|
|
16
|
+
|
|
17
|
+
`JsonFileSubstrate` is the baseline; it exists to change nothing. This one is
|
|
18
|
+
the reason the port exists at all:
|
|
19
|
+
|
|
20
|
+
- **It has no shared disk.** Two processes on different machines can reach the
|
|
21
|
+
same database, which is what makes a gate blocked on one runner resumable on
|
|
22
|
+
another (spec AC-3).
|
|
23
|
+
- **`lock` is one lock.** A transaction covers every key it is given, so the
|
|
24
|
+
lock-order inversion an external review found between the scope lock and the
|
|
25
|
+
state lock is removed *by construction* rather than by asking callers to
|
|
26
|
+
acquire in a careful order. `JsonFileSubstrate` can only sort; this cannot
|
|
27
|
+
invert.
|
|
28
|
+
- **`write(expect=)` is a real compare-and-swap**, done in SQL, so it does not
|
|
29
|
+
depend on the caller holding anything.
|
|
30
|
+
|
|
31
|
+
## The revision
|
|
32
|
+
|
|
33
|
+
A monotonically increasing integer per document, assigned by the write. Unlike
|
|
34
|
+
`JsonFileSubstrate`'s content hash it does not survive rewriting the same
|
|
35
|
+
content, and that is fine: a revision is an opaque token to compare, and the
|
|
36
|
+
port says so.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import sqlite3
|
|
43
|
+
import threading
|
|
44
|
+
from collections.abc import Iterator
|
|
45
|
+
from contextlib import contextmanager
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from functualize._types.errors import SubstrateUnreadableError
|
|
50
|
+
from functualize._types.protocols import Stored
|
|
51
|
+
|
|
52
|
+
__all__ = ["SQLiteSubstrate"]
|
|
53
|
+
|
|
54
|
+
_SCHEMA = """
|
|
55
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
56
|
+
key TEXT PRIMARY KEY,
|
|
57
|
+
payload TEXT NOT NULL,
|
|
58
|
+
revision INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SQLiteSubstrate:
|
|
64
|
+
"""Documents in one SQLite table, reachable from any process."""
|
|
65
|
+
|
|
66
|
+
__slots__ = ("_local", "_path")
|
|
67
|
+
|
|
68
|
+
def __init__(self, path: Path | str) -> None:
|
|
69
|
+
self._path = Path(path)
|
|
70
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
#: One connection per thread. SQLite connections are not safe to share
|
|
72
|
+
#: across threads, and `invoke_parallel` runs workers on threads that
|
|
73
|
+
#: all reach the same substrate.
|
|
74
|
+
self._local = threading.local()
|
|
75
|
+
self._conn().executescript(_SCHEMA)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def path(self) -> Path:
|
|
79
|
+
"""The database file."""
|
|
80
|
+
return self._path
|
|
81
|
+
|
|
82
|
+
def _conn(self) -> sqlite3.Connection:
|
|
83
|
+
conn = getattr(self._local, "conn", None)
|
|
84
|
+
if conn is None:
|
|
85
|
+
conn = sqlite3.connect(str(self._path), isolation_level=None)
|
|
86
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
conn.execute("PRAGMA busy_timeout=10000")
|
|
88
|
+
self._local.conn = conn
|
|
89
|
+
self._local.depth = 0
|
|
90
|
+
return conn
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# The port
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def read(self, key: str) -> Stored | None:
|
|
97
|
+
row = (
|
|
98
|
+
self._conn()
|
|
99
|
+
.execute("SELECT payload, revision FROM documents WHERE key = ?", (key,))
|
|
100
|
+
.fetchone()
|
|
101
|
+
)
|
|
102
|
+
if row is None:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(row[0])
|
|
106
|
+
except ValueError as exc:
|
|
107
|
+
raise SubstrateUnreadableError(key, str(exc)) from exc
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise SubstrateUnreadableError(
|
|
110
|
+
key, f"expected an object, found {type(data).__name__}"
|
|
111
|
+
)
|
|
112
|
+
return Stored(data=data, revision=int(row[1]))
|
|
113
|
+
|
|
114
|
+
def write(
|
|
115
|
+
self, key: str, payload: dict[str, Any], *, expect: int | None = None
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Replace the document, refusing when ``expect`` no longer matches.
|
|
118
|
+
|
|
119
|
+
The compare and the write are **one statement**, so unlike the
|
|
120
|
+
filesystem implementation this does not need the caller to hold a lock
|
|
121
|
+
to be exact. That is the property a backend with no `flock` has to
|
|
122
|
+
provide instead.
|
|
123
|
+
"""
|
|
124
|
+
blob = json.dumps(payload, indent=2, sort_keys=True)
|
|
125
|
+
conn = self._conn()
|
|
126
|
+
if expect is None:
|
|
127
|
+
conn.execute(
|
|
128
|
+
"INSERT INTO documents (key, payload, revision) VALUES (?, ?, 1) "
|
|
129
|
+
"ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
|
|
130
|
+
"revision = documents.revision + 1",
|
|
131
|
+
(key, blob),
|
|
132
|
+
)
|
|
133
|
+
return True
|
|
134
|
+
cursor = conn.execute(
|
|
135
|
+
"UPDATE documents SET payload = ?, revision = revision + 1 "
|
|
136
|
+
"WHERE key = ? AND revision = ?",
|
|
137
|
+
(blob, key, expect),
|
|
138
|
+
)
|
|
139
|
+
return cursor.rowcount == 1
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def lock(self, *keys: str) -> Iterator[None]:
|
|
143
|
+
"""**One** lock, covering every key — the whole point of the port.
|
|
144
|
+
|
|
145
|
+
A write transaction on the database excludes every other writer
|
|
146
|
+
regardless of which keys they named, so a caller that takes state then
|
|
147
|
+
scopes and a caller that takes scopes then state cannot deadlock
|
|
148
|
+
against each other. `JsonFileSubstrate` sorts its per-file locks, which
|
|
149
|
+
prevents a caller from inverting *within one call* and nothing more.
|
|
150
|
+
|
|
151
|
+
Re-entrant per thread, because a store takes the lock around a
|
|
152
|
+
read-modify-write that a wider batch may already hold.
|
|
153
|
+
"""
|
|
154
|
+
conn = self._conn()
|
|
155
|
+
depth = getattr(self._local, "depth", 0)
|
|
156
|
+
self._local.depth = depth + 1
|
|
157
|
+
try:
|
|
158
|
+
if depth == 0:
|
|
159
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
160
|
+
try:
|
|
161
|
+
yield
|
|
162
|
+
except BaseException:
|
|
163
|
+
conn.execute("ROLLBACK")
|
|
164
|
+
raise
|
|
165
|
+
else:
|
|
166
|
+
conn.execute("COMMIT")
|
|
167
|
+
else:
|
|
168
|
+
yield
|
|
169
|
+
finally:
|
|
170
|
+
self._local.depth = depth
|
|
171
|
+
|
|
172
|
+
def clear(self, key: str) -> str | None:
|
|
173
|
+
"""Copy the document to a backup key and remove the original.
|
|
174
|
+
|
|
175
|
+
Never decodes it — the escape hatch has to work on exactly the content
|
|
176
|
+
:meth:`read` refuses.
|
|
177
|
+
"""
|
|
178
|
+
conn = self._conn()
|
|
179
|
+
row = conn.execute(
|
|
180
|
+
"SELECT payload FROM documents WHERE key = ?", (key,)
|
|
181
|
+
).fetchone()
|
|
182
|
+
if row is None:
|
|
183
|
+
return None
|
|
184
|
+
backup = f"{key}.bak"
|
|
185
|
+
index = 1
|
|
186
|
+
while conn.execute(
|
|
187
|
+
"SELECT 1 FROM documents WHERE key = ?", (backup,)
|
|
188
|
+
).fetchone():
|
|
189
|
+
backup = f"{key}.bak.{index}"
|
|
190
|
+
index += 1
|
|
191
|
+
conn.execute(
|
|
192
|
+
"INSERT INTO documents (key, payload, revision) VALUES (?, ?, 1)",
|
|
193
|
+
(backup, row[0]),
|
|
194
|
+
)
|
|
195
|
+
conn.execute("DELETE FROM documents WHERE key = ?", (key,))
|
|
196
|
+
return f"{self._path}#{backup}"
|
|
197
|
+
|
|
198
|
+
def delete(self, key: str) -> bool:
|
|
199
|
+
cursor = self._conn().execute("DELETE FROM documents WHERE key = ?", (key,))
|
|
200
|
+
return cursor.rowcount > 0
|
|
201
|
+
|
|
202
|
+
def describe(self, key: str) -> str:
|
|
203
|
+
"""The database, the key, and the payload size.
|
|
204
|
+
|
|
205
|
+
A namespace key (``"scope-state/"``) is described in aggregate, as the
|
|
206
|
+
port requires, because scope state is one document per run.
|
|
207
|
+
"""
|
|
208
|
+
conn = self._conn()
|
|
209
|
+
if key.endswith("/"):
|
|
210
|
+
row = conn.execute(
|
|
211
|
+
"SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0) "
|
|
212
|
+
"FROM documents WHERE key LIKE ?",
|
|
213
|
+
(f"{key}%",),
|
|
214
|
+
).fetchone()
|
|
215
|
+
count, total = int(row[0]), int(row[1])
|
|
216
|
+
if not count:
|
|
217
|
+
return "empty"
|
|
218
|
+
plural = "" if count == 1 else "s"
|
|
219
|
+
return f"{count} document{plural}, {total} B in {self._path}"
|
|
220
|
+
row = conn.execute(
|
|
221
|
+
"SELECT LENGTH(payload) FROM documents WHERE key = ?", (key,)
|
|
222
|
+
).fetchone()
|
|
223
|
+
if row is None:
|
|
224
|
+
return f"{self._path}#{key} (absent)"
|
|
225
|
+
return f"{self._path}#{key} ({int(row[0])} B)"
|