lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""The `read` and `search` settings sections (design 8, 16), registered with the platform.
|
|
2
|
+
|
|
3
|
+
`read` bounds every read: page sizes, query complexity, traversal caps, time travel, diff and
|
|
4
|
+
log pages, and the statement timeout of read transactions. `search` holds the ranking knobs of
|
|
5
|
+
lexical, semantic and hybrid search. The text search configuration is the one the vectors were
|
|
6
|
+
built with (`store.materialize.text_search_config`) and the vector dimension is
|
|
7
|
+
`store.embeddings.dims`, so neither is repeated here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
from pydantic import Field, model_validator
|
|
15
|
+
|
|
16
|
+
from lorebase_core.query import QueryLimits
|
|
17
|
+
from lorebase_keeper.kindhealth.config import HealthSettings
|
|
18
|
+
from lorebase_platform.settings import SectionModel, Settings, register_section
|
|
19
|
+
from lorebase_store.bm25 import SearchBackend
|
|
20
|
+
|
|
21
|
+
READ_SECTION = "read"
|
|
22
|
+
SEARCH_SECTION = "search"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Bounds(SectionModel):
|
|
26
|
+
"""A page size default and its maximum."""
|
|
27
|
+
|
|
28
|
+
default_limit: int = Field(default=50, ge=1)
|
|
29
|
+
max_limit: int = Field(default=500, ge=1)
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="after")
|
|
32
|
+
def _default_within_max(self) -> Self:
|
|
33
|
+
if self.default_limit > self.max_limit:
|
|
34
|
+
raise ValueError("default_limit cannot exceed max_limit")
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class QuerySettings(SectionModel):
|
|
39
|
+
"""Complexity limits of the query DSL."""
|
|
40
|
+
|
|
41
|
+
max_via_depth: int = Field(default=3, ge=1, description="Nesting of via/count predicates.")
|
|
42
|
+
max_predicates: int = Field(default=64, ge=1, description="Predicates per query.")
|
|
43
|
+
max_in_values: int = Field(default=1000, ge=1, description="Operands of one `in`.")
|
|
44
|
+
max_order_terms: int = Field(default=4, ge=1, description="Terms of `order_by`.")
|
|
45
|
+
max_includes: int = Field(default=5, ge=0, description="Relations in `include`.")
|
|
46
|
+
include_default_limit: int = Field(default=10, ge=1, description="Neighbors per Record.")
|
|
47
|
+
include_max_limit: int = Field(default=50, ge=1)
|
|
48
|
+
max_aggregate_groups: int = Field(
|
|
49
|
+
default=1000, ge=1, description="Groups returned by `aggregate`; more are cut."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TraverseSettings(SectionModel):
|
|
54
|
+
"""`POST /traverse` and `POST /neighborhood` (design 8.3)."""
|
|
55
|
+
|
|
56
|
+
max_hops: int = Field(default=5, ge=1, description="Relations in one traversal path.")
|
|
57
|
+
max_nodes_per_hop: int = Field(default=1000, ge=1, description="Records reached per hop.")
|
|
58
|
+
max_seeds: int = Field(default=100, ge=1, description="Seed Records of a neighborhood.")
|
|
59
|
+
max_depth: int = Field(default=3, ge=1, description="Depth of a neighborhood.")
|
|
60
|
+
max_nodes: int = Field(default=2000, ge=1, description="Records in a neighborhood.")
|
|
61
|
+
max_edges: int = Field(default=5000, ge=1, description="Links in a neighborhood.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TimeTravelSettings(SectionModel):
|
|
65
|
+
"""Reads at an arbitrary Revision, served from the tree (design 6.12)."""
|
|
66
|
+
|
|
67
|
+
max_scan_entries: int = Field(
|
|
68
|
+
default=100_000,
|
|
69
|
+
ge=1,
|
|
70
|
+
description="Largest Kind or Relation partition a list read at a Revision scans; "
|
|
71
|
+
"larger ones are refused (read the materialized branch instead).",
|
|
72
|
+
)
|
|
73
|
+
snapshot_max_entries: int = Field(
|
|
74
|
+
default=100_000,
|
|
75
|
+
ge=0,
|
|
76
|
+
description="Most Records and Links a Revision may hold for reads at it that need "
|
|
77
|
+
"materialized rows (filters, ordering, includes, aggregates, Link summaries, "
|
|
78
|
+
"traversal, search, history, health, context packs); they are served from a snapshot "
|
|
79
|
+
"of the Revision, and refused above this size. 0 refuses them all.",
|
|
80
|
+
)
|
|
81
|
+
snapshot_max_history_revisions: int = Field(
|
|
82
|
+
default=100_000,
|
|
83
|
+
ge=1,
|
|
84
|
+
description="Most Revisions in the history of a Revision (its generation + 1, a bound "
|
|
85
|
+
"on its first-parent chain, whose changes a snapshot copies); deeper ones are refused.",
|
|
86
|
+
)
|
|
87
|
+
snapshot_max_per_domain: int = Field(
|
|
88
|
+
default=4,
|
|
89
|
+
ge=1,
|
|
90
|
+
description="Snapshots a Domain holds, those being deleted included. Building another "
|
|
91
|
+
"first drops the oldest that no read holds and that are older than "
|
|
92
|
+
"`snapshot_min_age_s`; when none can go, the read answers 503 with Retry-After.",
|
|
93
|
+
)
|
|
94
|
+
snapshot_min_age_s: int = Field(
|
|
95
|
+
default=60,
|
|
96
|
+
ge=0,
|
|
97
|
+
description="Grace period of a new snapshot: younger ones are not dropped for others.",
|
|
98
|
+
)
|
|
99
|
+
snapshot_max_concurrent_builds: int = Field(
|
|
100
|
+
default=2,
|
|
101
|
+
ge=1,
|
|
102
|
+
description="Snapshot builds running at once in this process. Each holds a second "
|
|
103
|
+
"database connection besides its read's, so keep it well below the pool size; builds "
|
|
104
|
+
"of one Domain also run one at a time.",
|
|
105
|
+
)
|
|
106
|
+
snapshot_build_wait_ms: int = Field(
|
|
107
|
+
default=5000,
|
|
108
|
+
ge=0,
|
|
109
|
+
description="How long a read waits for a build slot or for the Domain's running build "
|
|
110
|
+
"before answering 503 with Retry-After.",
|
|
111
|
+
)
|
|
112
|
+
snapshot_builds_per_minute: float = Field(
|
|
113
|
+
default=6,
|
|
114
|
+
gt=0,
|
|
115
|
+
description="Snapshot builds one principal may start per minute in this process "
|
|
116
|
+
"(token bucket); beyond it reads that need a new snapshot answer 429.",
|
|
117
|
+
)
|
|
118
|
+
snapshot_build_burst: int = Field(
|
|
119
|
+
default=3, ge=1, description="Snapshot builds one principal may start at once."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class DiffSettings(SectionModel):
|
|
124
|
+
"""Diff of two Revisions (design 6.7)."""
|
|
125
|
+
|
|
126
|
+
max_changes: int = Field(
|
|
127
|
+
default=100_000, ge=1, description="Changed elements a diff may hold; larger ones fail."
|
|
128
|
+
)
|
|
129
|
+
page: Bounds = Field(default_factory=lambda: Bounds(default_limit=100, max_limit=1000))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HistorySettings(Bounds):
|
|
133
|
+
"""Per-Record history and lineage (`.../records/{key}/history` and `/lineage`)."""
|
|
134
|
+
|
|
135
|
+
lineage_scan_changes: int = Field(
|
|
136
|
+
default=1000,
|
|
137
|
+
ge=1,
|
|
138
|
+
description="Newest changes of a Record lineage searches for the Revision behind a "
|
|
139
|
+
"value, or the change that created the Record, when the materialized row does not name "
|
|
140
|
+
"them (rows written before migration 0032 and not yet backfilled); older ones name no "
|
|
141
|
+
"Revision.",
|
|
142
|
+
)
|
|
143
|
+
lineage_count_max: int = Field(
|
|
144
|
+
default=10_000,
|
|
145
|
+
ge=1,
|
|
146
|
+
description="Changes of a Record lineage counts; beyond, `changes` is this bound and "
|
|
147
|
+
"`changes_truncated` is true.",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ReadSettings(SectionModel):
|
|
152
|
+
"""Bounds of the read path."""
|
|
153
|
+
|
|
154
|
+
records: Bounds = Field(default_factory=Bounds, description="Record and Link pages.")
|
|
155
|
+
log: Bounds = Field(default_factory=Bounds, description="Pages of the Revision log.")
|
|
156
|
+
history: HistorySettings = Field(
|
|
157
|
+
default_factory=HistorySettings, description="Per-Record history and lineage."
|
|
158
|
+
)
|
|
159
|
+
statement_timeout_ms: int = Field(
|
|
160
|
+
default=2000, ge=0, description="statement_timeout of read transactions; 0 disables."
|
|
161
|
+
)
|
|
162
|
+
schema_cache_entries: int = Field(
|
|
163
|
+
default=64, ge=1, description="Compiled schemas kept in memory, by schema hash."
|
|
164
|
+
)
|
|
165
|
+
derivation_preview: Bounds = Field(
|
|
166
|
+
default_factory=lambda: Bounds(default_limit=5, max_limit=25),
|
|
167
|
+
description="Records `POST /derivations:preview` evaluates a candidate Derivation on: "
|
|
168
|
+
"the default and the most one request may ask for.",
|
|
169
|
+
)
|
|
170
|
+
derivation_preview_max_key_length: int = Field(
|
|
171
|
+
default=1024,
|
|
172
|
+
ge=1,
|
|
173
|
+
description="Characters of each Record key `POST /derivations:preview` accepts in `keys`.",
|
|
174
|
+
)
|
|
175
|
+
source_preview_max_rows: int = Field(
|
|
176
|
+
default=100,
|
|
177
|
+
ge=1,
|
|
178
|
+
description="Sample rows `POST /sources:preview` evaluates the expression mappings of a "
|
|
179
|
+
"candidate Source on.",
|
|
180
|
+
)
|
|
181
|
+
source_preview_max_columns: int = Field(
|
|
182
|
+
default=200,
|
|
183
|
+
ge=1,
|
|
184
|
+
description="Columns of one sample row `POST /sources:preview` accepts.",
|
|
185
|
+
)
|
|
186
|
+
source_preview_max_cells: int = Field(
|
|
187
|
+
default=1000,
|
|
188
|
+
ge=1,
|
|
189
|
+
description="Expressions evaluated by one `POST /sources:preview`: sample rows times "
|
|
190
|
+
"Attributes mapped by an expression.",
|
|
191
|
+
)
|
|
192
|
+
query: QuerySettings = Field(default_factory=QuerySettings)
|
|
193
|
+
traverse: TraverseSettings = Field(default_factory=TraverseSettings)
|
|
194
|
+
time_travel: TimeTravelSettings = Field(default_factory=TimeTravelSettings)
|
|
195
|
+
diff: DiffSettings = Field(default_factory=DiffSettings)
|
|
196
|
+
health: HealthSettings = Field(default_factory=HealthSettings)
|
|
197
|
+
|
|
198
|
+
def query_limits(self) -> QueryLimits:
|
|
199
|
+
"""Limits handed to the DSL resolver."""
|
|
200
|
+
query = self.query
|
|
201
|
+
return QueryLimits(
|
|
202
|
+
default_limit=self.records.default_limit,
|
|
203
|
+
max_limit=self.records.max_limit,
|
|
204
|
+
max_via_depth=query.max_via_depth,
|
|
205
|
+
max_predicates=query.max_predicates,
|
|
206
|
+
max_in_values=query.max_in_values,
|
|
207
|
+
include_default_limit=query.include_default_limit,
|
|
208
|
+
include_max_limit=query.include_max_limit,
|
|
209
|
+
max_includes=query.max_includes,
|
|
210
|
+
max_order_terms=query.max_order_terms,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class SemanticSettings(SectionModel):
|
|
215
|
+
"""Vector search over `embeddings`. Query vectors always come from the caller."""
|
|
216
|
+
|
|
217
|
+
model: str = Field(
|
|
218
|
+
default="BAAI/bge-small-en-v1.5",
|
|
219
|
+
min_length=1,
|
|
220
|
+
description="Embedding model whose vectors are searched; equals "
|
|
221
|
+
"inference.embeddings.model. The dimension is store.embeddings.dims.",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class HybridSettings(SectionModel):
|
|
226
|
+
"""Reciprocal rank fusion of the lexical and semantic lists (design 8.4)."""
|
|
227
|
+
|
|
228
|
+
rrf_k: int = Field(default=60, ge=1, description="Rank offset of reciprocal rank fusion.")
|
|
229
|
+
lexical_weight: float = Field(default=1.0, ge=0)
|
|
230
|
+
semantic_weight: float = Field(default=1.0, ge=0)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class PartialMatchSettings(SectionModel):
|
|
234
|
+
"""Ranked partial matching of lexical search: descriptive queries rarely have every word
|
|
235
|
+
in one Record, so when the query as written (every term) finds too few, Records holding
|
|
236
|
+
some of its terms follow, after every full match."""
|
|
237
|
+
|
|
238
|
+
min_results: int = Field(
|
|
239
|
+
default=1,
|
|
240
|
+
ge=0,
|
|
241
|
+
description="Full matches below which partial matches are added; 0 turns partial "
|
|
242
|
+
"matching off.",
|
|
243
|
+
)
|
|
244
|
+
weight: float = Field(
|
|
245
|
+
default=0.5,
|
|
246
|
+
ge=0,
|
|
247
|
+
description="Partial matches score weight * (ts_rank_cd of the terms joined by OR + "
|
|
248
|
+
"term_bonus * terms matched).",
|
|
249
|
+
)
|
|
250
|
+
term_bonus: float = Field(
|
|
251
|
+
default=1.0, ge=0, description="Score added for each query term a partial match holds."
|
|
252
|
+
)
|
|
253
|
+
candidates: int = Field(
|
|
254
|
+
default=100,
|
|
255
|
+
ge=1,
|
|
256
|
+
description="Partial matches ranked per search, best by ts_rank_cd first; at most "
|
|
257
|
+
"store.reads.max_search_rows without filters, search.candidates with them.",
|
|
258
|
+
)
|
|
259
|
+
stop_words: str | None = Field(
|
|
260
|
+
default="english",
|
|
261
|
+
min_length=1,
|
|
262
|
+
description="Text search configuration whose stop words are left out of the terms of "
|
|
263
|
+
"partial matching (the, for, that...): the search configuration, simple by default, "
|
|
264
|
+
"keeps every word. null leaves only the search configuration's own stop words out.",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class SearchSettings(SectionModel):
|
|
269
|
+
"""Ranking of `POST /search`."""
|
|
270
|
+
|
|
271
|
+
results: Bounds = Field(default_factory=lambda: Bounds(default_limit=20, max_limit=100))
|
|
272
|
+
backend: SearchBackend = Field(
|
|
273
|
+
default="postgres",
|
|
274
|
+
description="Ranking of lexical search without filters: postgres (ts_rank_cd over the "
|
|
275
|
+
"GIN index, the default) or bm25 (the BM25 index of ParadeDB pg_search, an optional "
|
|
276
|
+
"AGPL-3.0 extension; store.storage.bm25_index, docs/search.md#bm25-ranking). Without "
|
|
277
|
+
"the extension or a valid index, search keeps postgres and /readyz reports it.",
|
|
278
|
+
)
|
|
279
|
+
candidates: int = Field(
|
|
280
|
+
default=200, ge=1, description="Rows of each ranked list before fusion and paging."
|
|
281
|
+
)
|
|
282
|
+
trigram_threshold: float = Field(
|
|
283
|
+
default=0.3,
|
|
284
|
+
gt=0,
|
|
285
|
+
le=1,
|
|
286
|
+
description="Least trigram similarity between a query word the branch does not hold "
|
|
287
|
+
"and a word of the branch that corrects it.",
|
|
288
|
+
)
|
|
289
|
+
trigram_weight: float = Field(
|
|
290
|
+
default=0.5,
|
|
291
|
+
ge=0,
|
|
292
|
+
description="Weight of ts_rank_cd for hits found only through a corrected word; they "
|
|
293
|
+
"always rank after the hits of the query as written.",
|
|
294
|
+
)
|
|
295
|
+
typo_candidates: int = Field(
|
|
296
|
+
default=3,
|
|
297
|
+
ge=0,
|
|
298
|
+
description="Corrections tried per unknown query word, most similar first; 0 turns "
|
|
299
|
+
"typo tolerance off.",
|
|
300
|
+
)
|
|
301
|
+
typo_min_length: int = Field(
|
|
302
|
+
default=4, ge=1, description="Shortest query word (in characters) that is corrected."
|
|
303
|
+
)
|
|
304
|
+
common_term_docs: int = Field(
|
|
305
|
+
default=2000,
|
|
306
|
+
ge=0,
|
|
307
|
+
description="Lexical search without filters ranks every match when the word lexicon "
|
|
308
|
+
"bounds the matches of q within this many Records; when every term of q is more "
|
|
309
|
+
"common, it ranks only the first search.common_term_candidates matches in index order "
|
|
310
|
+
"and the response says truncated_candidates. 0 turns this off: "
|
|
311
|
+
"store.reads.search_candidates matches are ranked, whatever the terms.",
|
|
312
|
+
)
|
|
313
|
+
common_term_candidates: int = Field(
|
|
314
|
+
default=1000,
|
|
315
|
+
ge=1,
|
|
316
|
+
description="Matches ranked when every term of q is above search.common_term_docs.",
|
|
317
|
+
)
|
|
318
|
+
headline_options: str = Field(
|
|
319
|
+
default="MaxWords=30, MinWords=10, MaxFragments=2",
|
|
320
|
+
min_length=1,
|
|
321
|
+
description="ts_headline options of result snippets.",
|
|
322
|
+
)
|
|
323
|
+
partial: PartialMatchSettings = Field(default_factory=PartialMatchSettings)
|
|
324
|
+
semantic: SemanticSettings = Field(default_factory=SemanticSettings)
|
|
325
|
+
hybrid: HybridSettings = Field(default_factory=HybridSettings)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
register_section(READ_SECTION, ReadSettings)
|
|
329
|
+
register_section(SEARCH_SECTION, SearchSettings)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def read_settings(settings: Settings) -> ReadSettings:
|
|
333
|
+
"""The validated `read` section."""
|
|
334
|
+
return settings.section(READ_SECTION, ReadSettings)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def search_settings(settings: Settings) -> SearchSettings:
|
|
338
|
+
"""The validated `search` section."""
|
|
339
|
+
return settings.section(SEARCH_SECTION, SearchSettings)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Materialized rows for reads at a past Revision (design 6.12).
|
|
2
|
+
|
|
3
|
+
Key reads and listings ordered by key at a Revision other than the branch head read the tree.
|
|
4
|
+
Reads that need materialized rows (filters, ordering, includes, aggregates, Link and lineage
|
|
5
|
+
summaries, traversal, neighborhoods, search, Record history, health, context packs and
|
|
6
|
+
explanations) read a snapshot of the Revision instead (`lorebase_store.materialize.snapshot`)
|
|
7
|
+
with the statements of a head read, so they answer what the head answered when the Revision
|
|
8
|
+
was the head. Callers validate and authorize the request before asking for the snapshot.
|
|
9
|
+
|
|
10
|
+
The snapshot is built on first use and shared by later reads of the Revision. The read holds
|
|
11
|
+
the snapshot's shared lock from before the build, so nothing drops it between the build and
|
|
12
|
+
the read. Building is bounded (`read.time_travel`):
|
|
13
|
+
|
|
14
|
+
- Revisions holding more than `snapshot_max_entries` Records and Links, or more than
|
|
15
|
+
`snapshot_max_history_revisions` Revisions of history, are refused (422 `time_travel`);
|
|
16
|
+
- each principal may start `snapshot_builds_per_minute` builds (burst `snapshot_build_burst`)
|
|
17
|
+
in this process, then gets 429 with Retry-After;
|
|
18
|
+
- at most `snapshot_max_concurrent_builds` builds run in this process, each on a second
|
|
19
|
+
database connection; builds of one Domain run one at a time; a read waits
|
|
20
|
+
`snapshot_build_wait_ms` for its turn, then gets 503 with Retry-After;
|
|
21
|
+
- unused snapshots older than `snapshot_min_age_s` are dropped in a short transaction before
|
|
22
|
+
a build; a Domain holding `snapshot_max_per_domain` snapshots none of which can go answers
|
|
23
|
+
503 with Retry-After.
|
|
24
|
+
|
|
25
|
+
Authorization and masking are those of the read point: a snapshot point differs only in where
|
|
26
|
+
its rows come from (`ReadPoint.layout`).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import math
|
|
33
|
+
from dataclasses import replace
|
|
34
|
+
|
|
35
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
|
+
|
|
37
|
+
from lorebase_keeper.errors import NOT_FOUND, RATE_LIMITED, UNAVAILABLE, ProblemError
|
|
38
|
+
from lorebase_keeper.ratelimit.bucket import BucketSpec
|
|
39
|
+
from lorebase_keeper.reading.points import ReadPoint
|
|
40
|
+
from lorebase_keeper.reading.problems import snapshot_refused
|
|
41
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
42
|
+
from lorebase_keeper.reading.tree import TreeReader
|
|
43
|
+
from lorebase_store.layout import BranchLayout
|
|
44
|
+
from lorebase_store.materialize.snapshot import (
|
|
45
|
+
SnapshotBusyError,
|
|
46
|
+
build_snapshot,
|
|
47
|
+
drop_unused,
|
|
48
|
+
hold_snapshot,
|
|
49
|
+
snapshot_entries,
|
|
50
|
+
snapshot_name,
|
|
51
|
+
)
|
|
52
|
+
from lorebase_store.repositories import scope_to_domain
|
|
53
|
+
from lorebase_store.repositories.refs import LockTimeoutError
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def with_rows(
|
|
57
|
+
session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, what: str
|
|
58
|
+
) -> ReadPoint:
|
|
59
|
+
"""`point` when materialized rows serve it, otherwise the same point read from a snapshot
|
|
60
|
+
of its Revision (built when missing). `what` names the feature in a refusal."""
|
|
61
|
+
if point.materialized:
|
|
62
|
+
return point
|
|
63
|
+
domain_id, revision = point.domain.id, point.revision
|
|
64
|
+
settings = runtime.read.time_travel
|
|
65
|
+
tree = await TreeReader(session, runtime, domain_id, revision.root).tree()
|
|
66
|
+
entries = snapshot_entries(tree)
|
|
67
|
+
if settings.snapshot_max_entries == 0 or entries > settings.snapshot_max_entries:
|
|
68
|
+
raise snapshot_refused(
|
|
69
|
+
what,
|
|
70
|
+
f"this one holds {entries} Records and Links, more than "
|
|
71
|
+
f"read.time_travel.snapshot_max_entries ({settings.snapshot_max_entries})",
|
|
72
|
+
)
|
|
73
|
+
history = revision.generation + 1
|
|
74
|
+
if history > settings.snapshot_max_history_revisions:
|
|
75
|
+
raise snapshot_refused(
|
|
76
|
+
what,
|
|
77
|
+
f"its history holds up to {history} Revisions, more than "
|
|
78
|
+
f"read.time_travel.snapshot_max_history_revisions "
|
|
79
|
+
f"({settings.snapshot_max_history_revisions})",
|
|
80
|
+
)
|
|
81
|
+
# Held from here on: a snapshot built below cannot be dropped before this read ends.
|
|
82
|
+
if not await hold_snapshot(session, domain_id, revision.id):
|
|
83
|
+
await _build(runtime, point)
|
|
84
|
+
if not await hold_snapshot(session, domain_id, revision.id):
|
|
85
|
+
raise _busy(runtime)
|
|
86
|
+
layout = BranchLayout(domain_id, snapshot_name(revision.id))
|
|
87
|
+
return replace(point, layout=layout, snapshot=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _build(runtime: ReadRuntime, point: ReadPoint) -> None:
|
|
91
|
+
"""Build the snapshot of the point's Revision within the build bounds (module doc)."""
|
|
92
|
+
settings = runtime.read.time_travel
|
|
93
|
+
spec = BucketSpec(
|
|
94
|
+
capacity=settings.snapshot_build_burst,
|
|
95
|
+
refill_per_s=settings.snapshot_builds_per_minute / 60,
|
|
96
|
+
)
|
|
97
|
+
principal = point.scope.guard.principal.id
|
|
98
|
+
decision = await runtime.snapshot_rates.take(f"{principal}\x00snapshot", spec)
|
|
99
|
+
if not decision.allowed:
|
|
100
|
+
raise ProblemError(
|
|
101
|
+
RATE_LIMITED,
|
|
102
|
+
"Too many snapshot builds for reads at past Revisions; retry later.",
|
|
103
|
+
headers={"Retry-After": str(decision.retry_after_s)},
|
|
104
|
+
)
|
|
105
|
+
wait_ms = settings.snapshot_build_wait_ms
|
|
106
|
+
try:
|
|
107
|
+
await asyncio.wait_for(runtime.snapshot_builds.acquire(), timeout=wait_ms / 1000)
|
|
108
|
+
except TimeoutError:
|
|
109
|
+
raise _busy(runtime) from None
|
|
110
|
+
domain_id = point.domain.id
|
|
111
|
+
try:
|
|
112
|
+
async with runtime.sessions() as writer:
|
|
113
|
+
async with writer.begin():
|
|
114
|
+
await scope_to_domain(writer, domain_id)
|
|
115
|
+
await drop_unused(
|
|
116
|
+
writer,
|
|
117
|
+
domain_id,
|
|
118
|
+
keep=settings.snapshot_max_per_domain - 1,
|
|
119
|
+
min_age_s=settings.snapshot_min_age_s,
|
|
120
|
+
runtime=runtime.store,
|
|
121
|
+
lock_timeout_ms=wait_ms,
|
|
122
|
+
)
|
|
123
|
+
async with writer.begin():
|
|
124
|
+
await scope_to_domain(writer, domain_id)
|
|
125
|
+
await build_snapshot(
|
|
126
|
+
writer,
|
|
127
|
+
domain_id,
|
|
128
|
+
point.revision,
|
|
129
|
+
max_snapshots=settings.snapshot_max_per_domain,
|
|
130
|
+
runtime=runtime.store,
|
|
131
|
+
lock_timeout_ms=wait_ms,
|
|
132
|
+
)
|
|
133
|
+
except (SnapshotBusyError, LockTimeoutError):
|
|
134
|
+
raise _busy(runtime) from None
|
|
135
|
+
except LookupError:
|
|
136
|
+
raise ProblemError(NOT_FOUND, f"Revision {point.revision_hex} not found.") from None
|
|
137
|
+
finally:
|
|
138
|
+
runtime.snapshot_builds.release()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _busy(runtime: ReadRuntime) -> ProblemError:
|
|
142
|
+
retry = max(1, math.ceil(runtime.read.time_travel.snapshot_build_wait_ms / 1000))
|
|
143
|
+
return ProblemError(
|
|
144
|
+
UNAVAILABLE,
|
|
145
|
+
"No snapshot of this Revision can be built right now (other builds or snapshots in "
|
|
146
|
+
"use); retry shortly.",
|
|
147
|
+
headers={"Retry-After": str(retry)},
|
|
148
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Reads from a Revision tree (time travel, design 6.12).
|
|
2
|
+
|
|
3
|
+
Key lookups walk the trie (prefetched path, then a cached node read per level); listings scan
|
|
4
|
+
the whole Kind or Relation partition, sort its keys and page through them, so their cost grows
|
|
5
|
+
with the partition. Partitions above `read.time_travel.max_scan_entries` are refused.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterable, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
15
|
+
from sqlalchemy.orm import Session
|
|
16
|
+
|
|
17
|
+
from lorebase_core.tree import Tree, tree_get
|
|
18
|
+
from lorebase_keeper.errors import VALIDATION_FAILED, ProblemError
|
|
19
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
20
|
+
from lorebase_store.nodestore import NodeRepository, PgNodeStore
|
|
21
|
+
from lorebase_store.objectstore import PgObjectStore
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class TreeEntry:
|
|
26
|
+
key: str
|
|
27
|
+
object_hash: bytes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TreeReader:
|
|
31
|
+
"""Reads of one Revision tree in one Domain."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, session: AsyncSession, runtime: ReadRuntime, domain_id: str, root: bytes
|
|
35
|
+
) -> None:
|
|
36
|
+
settings = runtime.store_settings
|
|
37
|
+
self._session = session
|
|
38
|
+
self._runtime = runtime
|
|
39
|
+
self._domain = domain_id
|
|
40
|
+
self._root = root
|
|
41
|
+
self._nodes = NodeRepository(
|
|
42
|
+
session,
|
|
43
|
+
domain_id,
|
|
44
|
+
writes=settings.writes,
|
|
45
|
+
cache=runtime.store.node_cache,
|
|
46
|
+
batch=settings.nodes.prefetch_batch,
|
|
47
|
+
)
|
|
48
|
+
self._objects = PgObjectStore(
|
|
49
|
+
session, domain_id, writes=settings.writes, batch=settings.materialize.read_batch_rows
|
|
50
|
+
)
|
|
51
|
+
self._tree: Tree | None = None
|
|
52
|
+
|
|
53
|
+
async def tree(self) -> Tree:
|
|
54
|
+
if self._tree is None:
|
|
55
|
+
self._tree = await self._nodes.load_tree(self._root)
|
|
56
|
+
return self._tree
|
|
57
|
+
|
|
58
|
+
async def count(self, partition: str) -> int:
|
|
59
|
+
ref = (await self.tree()).partitions.get(partition)
|
|
60
|
+
return 0 if ref is None else ref.count
|
|
61
|
+
|
|
62
|
+
async def get(self, partition: str, key: str) -> bytes | None:
|
|
63
|
+
"""Object hash stored under `key`, or None."""
|
|
64
|
+
settings = self._runtime.store_settings
|
|
65
|
+
cache = self._runtime.store.node_cache
|
|
66
|
+
domain, root = self._domain, self._root
|
|
67
|
+
|
|
68
|
+
def lookup(sync: Session) -> bytes | None:
|
|
69
|
+
store = PgNodeStore(
|
|
70
|
+
sync, domain, cache=cache, prefetch_batch=settings.nodes.prefetch_batch
|
|
71
|
+
)
|
|
72
|
+
store.prefetch_paths(root, [(partition, key)])
|
|
73
|
+
return tree_get(store, root, partition, key)
|
|
74
|
+
|
|
75
|
+
return await self._session.run_sync(lookup)
|
|
76
|
+
|
|
77
|
+
async def entries(self, partition: str) -> list[TreeEntry]:
|
|
78
|
+
"""Every entry of a partition sorted by key; refused above the configured size."""
|
|
79
|
+
limit = self._runtime.read.time_travel.max_scan_entries
|
|
80
|
+
ref = (await self.tree()).partitions.get(partition)
|
|
81
|
+
if ref is None:
|
|
82
|
+
return []
|
|
83
|
+
if ref.count > limit:
|
|
84
|
+
raise ProblemError(
|
|
85
|
+
VALIDATION_FAILED,
|
|
86
|
+
f"Listing at a Revision scans the whole partition; this one holds {ref.count} "
|
|
87
|
+
f"entries, more than the limit of {limit}. Read the materialized branch instead.",
|
|
88
|
+
)
|
|
89
|
+
found: list[TreeEntry] = []
|
|
90
|
+
async for batch in self._nodes.scan(ref.root):
|
|
91
|
+
found.extend(TreeEntry(key, digest) for key, digest in batch)
|
|
92
|
+
found.sort(key=lambda entry: entry.key)
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
async def bodies(self, hashes: Iterable[bytes]) -> dict[bytes, Mapping[str, Any]]:
|
|
96
|
+
"""Stored object bodies by hash."""
|
|
97
|
+
stored = await self._objects.get_many(hashes)
|
|
98
|
+
return {digest: body.body for digest, body in stored.items()}
|