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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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)
|