lorebase-store 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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,298 @@
1
+ """MySQL, MariaDB and SQL Server profiler: catalog reflection (SQLAlchemy) plus a bounded
2
+ SELECT per table, profiled as records (``records.RecordProfiler``).
3
+
4
+ The sample is ``sample_rows`` rows (``LIMIT`` or ``TOP``, from the dialect); with ``count_rows``
5
+ each table is also counted, so inference knows when the sample is partial. Declared primary
6
+ keys and foreign keys come from the catalog. MySQL sessions are read-only and bounded by
7
+ ``max_execution_time`` (``max_statement_time`` on MariaDB); SQL Server has no read-only
8
+ transaction, so the login must only be able to read (its waits are bounded by the driver
9
+ timeout and ``LOCK_TIMEOUT``). MySQL runs through asyncmy; pymssql is blocking and runs in a
10
+ worker thread. The drivers are optional extras of the installing package (``SqlDriverError``
11
+ names them when missing). Binary values (a ``rowversion``) are profiled as hex text.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import importlib
18
+ import math
19
+ import time
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass, replace
22
+ from typing import Any, Literal
23
+
24
+ from sqlalchemy import (
25
+ URL,
26
+ ColumnElement,
27
+ Connection,
28
+ column,
29
+ create_engine,
30
+ func,
31
+ inspect,
32
+ make_url,
33
+ select,
34
+ text,
35
+ )
36
+ from sqlalchemy import table as sql_table
37
+ from sqlalchemy.exc import ArgumentError
38
+ from sqlalchemy.ext.asyncio import create_async_engine
39
+ from sqlalchemy.pool import NullPool
40
+ from sqlalchemy.types import BINARY, VARBINARY, LargeBinary, NullType, String, TypeEngine
41
+
42
+ from lorebase_core.infer import DeclaredForeignKey, TableProfile
43
+ from lorebase_store.profiling.records import RecordProfileError, RecordProfiler
44
+ from lorebase_store.settings import RecordProfilingSettings, SqlProfilingSettings
45
+
46
+ SqlDialect = Literal["mysql", "mssql"]
47
+
48
+ APPLICATION_NAME = "lorebase-keeper"
49
+
50
+
51
+ class SqlProfileError(ValueError):
52
+ """The requested tables cannot be profiled."""
53
+
54
+
55
+ class SqlDriverError(SqlProfileError):
56
+ """The driver of the database is not installed."""
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class _Dialect:
61
+ schemes: frozenset[str]
62
+ driver_name: str
63
+ module: str
64
+ extra: str
65
+
66
+
67
+ _DIALECTS: dict[SqlDialect, _Dialect] = {
68
+ "mysql": _Dialect(
69
+ frozenset({"mysql", "mariadb", "mysql+asyncmy", "mariadb+asyncmy"}),
70
+ "mysql+asyncmy",
71
+ "asyncmy",
72
+ "mysql",
73
+ ),
74
+ "mssql": _Dialect(
75
+ frozenset({"mssql", "sqlserver", "mssql+pymssql"}), "mssql+pymssql", "pymssql", "mssql"
76
+ ),
77
+ }
78
+
79
+
80
+ def _url(dialect: SqlDialect, location: str) -> URL:
81
+ """The URL on the driver of `dialect`; refuses other schemes without echoing the value."""
82
+ found = _DIALECTS[dialect]
83
+ try:
84
+ importlib.import_module(found.module)
85
+ except ImportError:
86
+ raise SqlDriverError(
87
+ f"{dialect} sources need {found.module}: install lorebase-keeper[{found.extra}]"
88
+ ) from None
89
+ try:
90
+ parsed = make_url(location)
91
+ except ArgumentError:
92
+ raise SqlProfileError("the connection is not a valid database URL") from None
93
+ if parsed.drivername not in found.schemes:
94
+ raise SqlProfileError(f"the connection must be a {dialect}:// URL")
95
+ return parsed.set(drivername=found.driver_name)
96
+
97
+
98
+ def _plain(value: Any) -> Any:
99
+ """Binary values (a rowversion) as hex text, the rest unchanged."""
100
+ if isinstance(value, bytes | bytearray | memoryview):
101
+ return bytes(value).hex()
102
+ return value
103
+
104
+
105
+ def _check_schema(
106
+ inspector: Any, schema: str | None, allowed: Sequence[str] | None, max_chars: int = 128
107
+ ) -> None:
108
+ """Refuse a schema that is not a plain name of this database's catalog, or that is not
109
+ among the `allowed` ones (the connection's own schema when none is named)."""
110
+ if schema is not None:
111
+ if not schema or len(schema) > max_chars or "." in schema or not schema.isprintable():
112
+ raise SqlProfileError("the schema is not a plain schema name")
113
+ if schema not in inspector.get_schema_names():
114
+ raise SqlProfileError(f"unknown schema {schema!r}")
115
+ if allowed is not None:
116
+ effective = schema if schema is not None else inspector.default_schema_name
117
+ if effective not in allowed:
118
+ raise SqlProfileError("the schema is not allowed for this source")
119
+
120
+
121
+ def _readable(column_name: str, sql_type: TypeEngine[Any], source: Any, limit: int) -> Any:
122
+ """The select item for a column, bounded: long or unbounded text is cut to `limit`
123
+ characters, binary columns without a small declared length and columns of unknown type
124
+ are not read (`None`)."""
125
+ found = source.c[column_name]
126
+ if isinstance(sql_type, NullType):
127
+ return None
128
+ if isinstance(sql_type, LargeBinary | BINARY | VARBINARY):
129
+ length = getattr(sql_type, "length", None)
130
+ return found if length is not None and length <= limit else None
131
+ if isinstance(sql_type, String):
132
+ length = sql_type.length
133
+ if length is None or length > limit:
134
+ return func.substring(found, 1, limit).label(column_name)
135
+ return found
136
+
137
+
138
+ def _profile_tables(
139
+ connection: Connection,
140
+ schema: str | None,
141
+ names: Sequence[str] | None,
142
+ settings: SqlProfilingSettings,
143
+ records: RecordProfilingSettings,
144
+ allowed_schemas: Sequence[str] | None = None,
145
+ ) -> list[TableProfile]:
146
+ inspector = inspect(connection)
147
+ _check_schema(inspector, schema, allowed_schemas)
148
+ wanted = set(names or ())
149
+ if len(wanted) > settings.max_tables:
150
+ raise SqlProfileError(
151
+ f"{len(wanted)} tables requested, more than the {settings.max_tables} allowed"
152
+ )
153
+ available = sorted(inspector.get_table_names(schema=schema))
154
+ if wanted:
155
+ missing = sorted(wanted - set(available))
156
+ if missing:
157
+ where = f"schema {schema!r}" if schema else "the database"
158
+ raise SqlProfileError(f"unknown tables in {where}: {', '.join(missing)}")
159
+ chosen = [name for name in available if name in wanted]
160
+ else:
161
+ chosen = available
162
+ if len(chosen) > settings.max_tables:
163
+ raise SqlProfileError(
164
+ f"{len(chosen)} tables requested, more than the {settings.max_tables} allowed"
165
+ )
166
+ profiles = []
167
+ deadline = time.monotonic() + settings.total_timeout_s
168
+ for name in chosen:
169
+ if time.monotonic() > deadline:
170
+ raise SqlProfileError(
171
+ f"reading the tables took longer than the {settings.total_timeout_s:g} s allowed"
172
+ )
173
+ info = inspector.get_columns(name, schema=schema)
174
+ source = sql_table(name, *(column(str(item["name"])) for item in info), schema=schema)
175
+ limit = settings.max_value_chars
176
+ items = [
177
+ (str(item["name"]), _readable(str(item["name"]), item["type"], source, limit))
178
+ for item in info
179
+ ]
180
+ read: list[tuple[str, ColumnElement[Any]]] = [(n, e) for n, e in items if e is not None]
181
+ if not read:
182
+ continue # nothing readable: no columns, or only unreadable ones
183
+ columns = [n for n, _ in read]
184
+ profiler = RecordProfiler(records)
185
+ query = select(*(e for _, e in read)).select_from(source).limit(settings.sample_rows)
186
+ for row in connection.execute(query):
187
+ profiler.add({c: _plain(value) for c, value in zip(columns, row, strict=True)})
188
+ try:
189
+ profile = profiler.profile(name, columns)
190
+ except RecordProfileError as error:
191
+ raise SqlProfileError(f"{name}: {error}") from None
192
+ sampled = profile.row_count or 0
193
+ count = (
194
+ connection.execute(select(func.count()).select_from(source)).scalar_one()
195
+ if settings.count_rows
196
+ else None
197
+ )
198
+ primary = tuple(
199
+ inspector.get_pk_constraint(name, schema=schema).get("constrained_columns") or ()
200
+ )
201
+ foreign_keys = tuple(
202
+ DeclaredForeignKey(
203
+ tuple(fk["constrained_columns"]),
204
+ fk["referred_table"],
205
+ tuple(fk["referred_columns"]),
206
+ )
207
+ for fk in inspector.get_foreign_keys(name, schema=schema)
208
+ if fk.get("referred_schema") in (None, schema)
209
+ )
210
+ profiles.append(
211
+ replace(
212
+ profile,
213
+ row_count=profile.row_count if count is None else max(int(count), sampled),
214
+ primary_key=primary or None,
215
+ foreign_keys=foreign_keys,
216
+ )
217
+ )
218
+ return profiles
219
+
220
+
221
+ async def _profile_mysql(
222
+ url: URL,
223
+ settings: SqlProfilingSettings,
224
+ run: Callable[[Connection], list[TableProfile]],
225
+ engine_factory: Callable[..., Any],
226
+ ) -> list[TableProfile]:
227
+ connect_args: dict[str, Any] = {
228
+ "connect_timeout": settings.connect_timeout_s,
229
+ "program_name": APPLICATION_NAME,
230
+ }
231
+ if "charset" not in url.query:
232
+ connect_args["charset"] = "utf8mb4"
233
+ engine = engine_factory(url, poolclass=NullPool, connect_args=connect_args)
234
+ try:
235
+ async with engine.connect() as connection:
236
+ # Session-wide, so that it holds for the transaction the first query opens.
237
+ await connection.execute(text("SET SESSION TRANSACTION READ ONLY"))
238
+ timeout = int(settings.statement_timeout_ms)
239
+ if getattr(connection.dialect, "is_mariadb", False):
240
+ limit = f"SET SESSION max_statement_time = {math.ceil(timeout / 1000)}"
241
+ else:
242
+ limit = f"SET SESSION max_execution_time = {timeout}"
243
+ await connection.execute(text(limit))
244
+ await connection.rollback()
245
+ return list(await connection.run_sync(run))
246
+ finally:
247
+ await engine.dispose()
248
+
249
+
250
+ def _mssql_work(
251
+ url: URL,
252
+ settings: SqlProfilingSettings,
253
+ run: Callable[[Connection], list[TableProfile]],
254
+ engine_factory: Callable[..., Any],
255
+ ) -> list[TableProfile]:
256
+ # pymssql cannot send ApplicationIntent=ReadOnly: the login must be able to read only.
257
+ timeout_ms = int(settings.statement_timeout_ms)
258
+ engine = engine_factory(
259
+ url,
260
+ poolclass=NullPool,
261
+ connect_args={
262
+ "login_timeout": math.ceil(settings.connect_timeout_s),
263
+ "timeout": math.ceil(timeout_ms / 1000),
264
+ "appname": APPLICATION_NAME,
265
+ },
266
+ )
267
+ try:
268
+ with engine.connect() as connection:
269
+ connection.execute(text(f"SET LOCK_TIMEOUT {timeout_ms}"))
270
+ return run(connection)
271
+ finally:
272
+ engine.dispose()
273
+
274
+
275
+ async def profile_sql_database(
276
+ dialect: SqlDialect,
277
+ location: str,
278
+ settings: SqlProfilingSettings,
279
+ records: RecordProfilingSettings,
280
+ *,
281
+ schema: str | None = None,
282
+ tables: Sequence[str] | None = None,
283
+ allowed_schemas: Sequence[str] | None = None,
284
+ engine_factory: Callable[..., Any] | None = None,
285
+ ) -> list[TableProfile]:
286
+ """Profiles of ``tables`` (default: every table) of a MySQL, MariaDB or SQL Server database
287
+ named by the connection URL ``location``; ``schema`` defaults to the connection's own and
288
+ must be a plain name found in the catalog (and in ``allowed_schemas`` when given; the
289
+ connection's own schema is then checked too). ``engine_factory`` replaces the engine
290
+ constructor (tests)."""
291
+ url = _url(dialect, location)
292
+
293
+ def run(connection: Connection) -> list[TableProfile]:
294
+ return _profile_tables(connection, schema, tables, settings, records, allowed_schemas)
295
+
296
+ if dialect == "mysql":
297
+ return await _profile_mysql(url, settings, run, engine_factory or create_async_engine)
298
+ return await asyncio.to_thread(_mssql_work, url, settings, run, engine_factory or create_engine)
@@ -0,0 +1,129 @@
1
+ """Token buckets shared by every Keeper (`keeper.limits.rate.backend: postgres`).
2
+
3
+ A bucket is one row of the UNLOGGED `rate_limit_buckets`, keyed by the SHA-256 of its name.
4
+ `lease` refills a bucket to the database clock and takes up to `lease` whole tokens in one
5
+ statement (an upsert, so the row lock makes it atomic across Keepers); a Keeper then serves
6
+ requests from the tokens it leased without another round trip. Tokens leave the shared bucket
7
+ only through leases, so all Keepers together never exceed the limit; leased tokens a Keeper
8
+ does not use in time are lost, which only makes the limit stricter. Time is the database's
9
+ (`clock_timestamp()`), so the clocks of the Keepers do not matter.
10
+
11
+ Rows of buckets that are full again are redundant (a missing bucket is a full one): `prune`
12
+ deletes them.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ from dataclasses import dataclass
19
+
20
+ from sqlalchemy import text
21
+ from sqlalchemy.ext.asyncio import AsyncSession
22
+
23
+ _NOW = "extract(epoch FROM clock_timestamp())"
24
+ _REFILLED = (
25
+ "LEAST(CAST(:capacity AS float8), b.tokens + GREATEST(0, EXCLUDED.updated - b.updated) "
26
+ "* CAST(:refill AS float8))"
27
+ )
28
+ _GRANT = (
29
+ f"CASE WHEN {_REFILLED} >= CAST(:cost AS float8) "
30
+ f"THEN GREATEST(CAST(:cost AS float8), LEAST(CAST(:lease AS float8), floor({_REFILLED}))) "
31
+ "ELSE 0 END"
32
+ )
33
+ _FULL_AT = "{updated} + (CAST(:capacity AS float8) - {tokens}) / CAST(:refill AS float8)"
34
+
35
+ _LEASE = text(
36
+ f"""
37
+ INSERT INTO rate_limit_buckets AS b (key, tokens, updated, full_at, granted)
38
+ SELECT :key, CAST(:capacity AS float8) - g.taken, g.now,
39
+ g.now + g.taken / CAST(:refill AS float8), g.taken
40
+ FROM (SELECT {_NOW} AS now,
41
+ LEAST(CAST(:lease AS float8), floor(CAST(:capacity AS float8))) AS taken) AS g
42
+ ON CONFLICT (key) DO UPDATE SET
43
+ tokens = {_REFILLED} - {_GRANT},
44
+ updated = GREATEST(b.updated, EXCLUDED.updated),
45
+ full_at = {
46
+ _FULL_AT.format(
47
+ updated="GREATEST(b.updated, EXCLUDED.updated)", tokens=f"({_REFILLED} - {_GRANT})"
48
+ )
49
+ },
50
+ granted = {_GRANT}
51
+ RETURNING tokens, granted, updated
52
+ """
53
+ )
54
+
55
+ _PEEK = text(f"SELECT tokens, updated, {_NOW} FROM rate_limit_buckets WHERE key = :key")
56
+ _CLOCK = text(f"SELECT {_NOW}")
57
+ _PRUNE = text(
58
+ f"""
59
+ DELETE FROM rate_limit_buckets WHERE key IN (
60
+ SELECT key FROM rate_limit_buckets WHERE full_at < {_NOW} LIMIT :limit
61
+ )
62
+ """
63
+ )
64
+
65
+
66
+ def bucket_key(name: str) -> bytes:
67
+ """Stored key of a bucket name."""
68
+ return hashlib.sha256(name.encode("utf-8")).digest()
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class Lease:
73
+ """Tokens taken from a shared bucket: `granted` (0 when it held fewer than the cost), the
74
+ tokens left in it after the lease and the database time, in epoch seconds."""
75
+
76
+ granted: float
77
+ remaining: float
78
+ now: float
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class BucketReading:
83
+ """Tokens of a bucket at `updated`, and the database time `now` (epoch seconds). A bucket
84
+ that has no row is full: `tokens` is None."""
85
+
86
+ tokens: float | None
87
+ updated: float
88
+ now: float
89
+
90
+
91
+ async def lease(
92
+ session: AsyncSession,
93
+ name: str,
94
+ *,
95
+ capacity: int,
96
+ refill_per_s: float,
97
+ lease: int,
98
+ cost: float = 1.0,
99
+ ) -> Lease:
100
+ """Refill the bucket and take up to `lease` whole tokens, at least `cost`, or none when it
101
+ holds fewer than `cost`. A bucket without a row starts full."""
102
+ row = (
103
+ await session.execute(
104
+ _LEASE,
105
+ {
106
+ "key": bucket_key(name),
107
+ "capacity": capacity,
108
+ "refill": refill_per_s,
109
+ "lease": max(lease, cost),
110
+ "cost": cost,
111
+ },
112
+ )
113
+ ).one()
114
+ return Lease(granted=float(row[1]), remaining=float(row[0]), now=float(row[2]))
115
+
116
+
117
+ async def read(session: AsyncSession, name: str) -> BucketReading:
118
+ """The stored state of a bucket, without changing it."""
119
+ row = (await session.execute(_PEEK, {"key": bucket_key(name)})).one_or_none()
120
+ if row is None:
121
+ now = float((await session.execute(_CLOCK)).scalar_one())
122
+ return BucketReading(None, now, now)
123
+ return BucketReading(float(row[0]), float(row[1]), float(row[2]))
124
+
125
+
126
+ async def prune(session: AsyncSession, *, limit: int) -> int:
127
+ """Delete up to `limit` rows of buckets that are full again; the number deleted."""
128
+ result = await session.execute(_PRUNE, {"limit": limit})
129
+ return int(getattr(result, "rowcount", 0) or 0)