smart-data-engine-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. sde/__init__.py +318 -0
  2. sde/_cutover_project.py +179 -0
  3. sde/_local_state.py +188 -0
  4. sde/_operator_deadline.py +50 -0
  5. sde/_usage.py +314 -0
  6. sde/bulk.py +79 -0
  7. sde/canonical.py +141 -0
  8. sde/capabilities.py +62 -0
  9. sde/cutover.py +286 -0
  10. sde/engines/__init__.py +0 -0
  11. sde/engines/_clickhouse_connection.py +224 -0
  12. sde/engines/_index_build.py +294 -0
  13. sde/engines/_operator.py +394 -0
  14. sde/engines/_staging.py +222 -0
  15. sde/engines/_storage.py +22 -0
  16. sde/engines/_write_fences.py +271 -0
  17. sde/engines/clickhouse.py +1115 -0
  18. sde/engines/orderbook.py +457 -0
  19. sde/engines/postgres.py +967 -0
  20. sde/entity.py +170 -0
  21. sde/errors.py +103 -0
  22. sde/explain.py +300 -0
  23. sde/frozen_verification.py +152 -0
  24. sde/generation.py +131 -0
  25. sde/groups.py +97 -0
  26. sde/hashing.py +242 -0
  27. sde/index_build.py +313 -0
  28. sde/index_operator.py +347 -0
  29. sde/infer.py +461 -0
  30. sde/inspection.py +62 -0
  31. sde/internal.py +90 -0
  32. sde/layout.py +669 -0
  33. sde/local_cutover.py +801 -0
  34. sde/logging.py +143 -0
  35. sde/migration.py +856 -0
  36. sde/model.py +482 -0
  37. sde/physical.py +531 -0
  38. sde/placement.py +1010 -0
  39. sde/provisioning.py +63 -0
  40. sde/py.typed +0 -0
  41. sde/query.py +521 -0
  42. sde/routing.py +85 -0
  43. sde/schema.py +466 -0
  44. sde/session.py +993 -0
  45. sde/shapes.py +153 -0
  46. sde/staging.py +264 -0
  47. sde/staging_operator.py +393 -0
  48. sde/telemetry.py +1087 -0
  49. sde/testing/__init__.py +14 -0
  50. sde/testing/loader.py +175 -0
  51. sde/testing/memory.py +331 -0
  52. sde/types.py +228 -0
  53. sde/verification.py +220 -0
  54. sde/watermark.py +222 -0
  55. sde/write_fence.py +283 -0
  56. sde_demo/__init__.py +1 -0
  57. sde_demo/__main__.py +183 -0
  58. sde_demo/diagnostics.py +92 -0
  59. sde_demo/model.py +75 -0
  60. sde_demo/project.py +312 -0
  61. sde_demo/py.typed +0 -0
  62. sde_demo/query_count.py +301 -0
  63. sde_demo/resources.py +969 -0
  64. sde_demo/runtime.py +419 -0
  65. sde_demo/verification.py +242 -0
  66. sde_operator/__init__.py +1 -0
  67. sde_operator/__main__.py +210 -0
  68. smart_data_engine_sdk-0.1.0.dist-info/METADATA +174 -0
  69. smart_data_engine_sdk-0.1.0.dist-info/RECORD +73 -0
  70. smart_data_engine_sdk-0.1.0.dist-info/WHEEL +4 -0
  71. smart_data_engine_sdk-0.1.0.dist-info/entry_points.txt +3 -0
  72. smart_data_engine_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
  73. smart_data_engine_sdk-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,967 @@
1
+ """PostgreSQL adapter.
2
+
3
+ Two rules run through everything here, and both come straight from the requirements rather than
4
+ from taste.
5
+
6
+ **A failed write is reported, never worked around.** No retry into another engine, no swallowing,
7
+ no "eventually consistent" story invented on the spot. If the source engine for a group will not
8
+ take the write, the client's code finds out (:class:`~sde.errors.EngineError`). The library
9
+ swallows its *own* internal problems - routing, telemetry - because a bug of ours must not take
10
+ down someone's application, but a write that did not happen is not our internal problem and
11
+ reporting success for it would be the single worst thing this library could do.
12
+
13
+ **Identifiers are quoted, always.** Not for injection - identifiers come from the placement map,
14
+ not from user input - but because entity names may contain non-ASCII characters, and an unquoted
15
+ identifier in PostgreSQL is folded to lower case in a way that is lossy for some of them.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Iterator, Mapping, Sequence
21
+ from contextlib import contextmanager
22
+ from dataclasses import replace
23
+ from typing import Any
24
+
25
+ from .._usage import UsageGate, guarded
26
+ from ..bulk import batch_columns
27
+ from ..errors import EngineError
28
+ from ..explain import (
29
+ Cost,
30
+ QueryPlan,
31
+ QueryPlanRefused,
32
+ postgres_findings,
33
+ )
34
+ from ..logging import log
35
+ from ..migration import key_columns, same_width
36
+ from ..physical import PhysicalFinding, declared_tables
37
+ from ..placement import BACKFILL_TABLE, WATERMARK_TABLE, PhysicalLayout
38
+ from ..query import ReadColumn, ReadPlan, read_row, read_sql, summary_sql
39
+ from ..schema import QUOTE, schema_statements
40
+ from ..write_fence import WriteFence
41
+ from ._write_fences import PostgresFences
42
+
43
+ __all__ = ["PostgresEngine"]
44
+
45
+
46
+ # Bound from the one definition in sde.schema, so that DDL and DML cannot disagree about
47
+ # how an identifier is escaped.
48
+ _quote = QUOTE["postgres"]
49
+
50
+
51
+ # Seconds. Not a guess about networks: this bounds *opening* a connection, which either completes
52
+ # in milliseconds on a healthy link or is not going to complete. Ten seconds leaves room for a
53
+ # saturated cross-region hop and still fails long before a request timeout that a caller sets. It
54
+ # is a default rather than a rule - a `connect_timeout` in the DSN wins - and it exists because the
55
+ # alternative, measured, is a call that never returns.
56
+ CONNECT_TIMEOUT_SECONDS = 10
57
+
58
+
59
+ class PostgresEngine:
60
+ """A thin adapter over psycopg. Deliberately thin: it executes decisions, it makes none."""
61
+
62
+ dialect = "postgres"
63
+
64
+ def __init__(self, dsn: str) -> None:
65
+ try:
66
+ import psycopg
67
+ except ImportError as exc: # pragma: no cover - depends on the install extra
68
+ raise EngineError(
69
+ "the PostgreSQL adapter needs the 'postgres' extra: "
70
+ "pip install 'smart-data-engine-sdk[postgres]'. "
71
+ "The core library has no dependencies, because it goes into your application and "
72
+ "every dependency here would be one you inherit."
73
+ ) from exc
74
+ self._psycopg = psycopg
75
+ self._dsn = dsn
76
+ self._conn: Any = None
77
+ self._usage = UsageGate()
78
+ self._unusable = False
79
+
80
+ # --- connection ------------------------------------------------------------------------
81
+
82
+ @guarded
83
+ def connect(self) -> None:
84
+ """Open the connection, with a bound on how long that may take.
85
+
86
+ Measured before this bound existed: a host that accepts the TCP connection and never
87
+ answers hung the call **for as long as the test was willing to wait**, because libpq has no
88
+ default ``connect_timeout`` and neither did we. That is not an exotic case - it is a
89
+ firewall that accepts, a load balancer with no healthy backend, a server mid-restart - and
90
+ without a bound it happens inside the caller's request path with nothing to time out.
91
+
92
+ The default is only applied when the caller has not chosen one. A ``connect_timeout`` in
93
+ the DSN is their decision about their own network and this must not override it.
94
+ """
95
+ if self._conn is not None and self._unusable:
96
+ raise EngineError(
97
+ "transaction completion was uncertain; close() then connect() before reuse"
98
+ )
99
+ if self._conn is None:
100
+ options: dict[str, Any] = {}
101
+ if "connect_timeout" not in self._dsn:
102
+ options["connect_timeout"] = CONNECT_TIMEOUT_SECONDS
103
+ try:
104
+ self._conn = self._psycopg.connect(self._dsn, autocommit=True, **options)
105
+ self._unusable = False
106
+ except Exception as exc:
107
+ raise EngineError(f"could not connect to PostgreSQL: {exc}") from exc
108
+
109
+ @guarded
110
+ def close(self) -> None:
111
+ if self._conn is not None:
112
+ self._conn.close()
113
+ self._conn = None
114
+
115
+ def __enter__(self) -> PostgresEngine:
116
+ self.connect()
117
+ return self
118
+
119
+ def __exit__(self, *_: object) -> None:
120
+ self.close()
121
+
122
+ @property
123
+ def _cx(self) -> Any:
124
+ if self._unusable:
125
+ raise EngineError(
126
+ "transaction completion was uncertain; close() then connect() before reuse"
127
+ )
128
+ if self._conn is None:
129
+ raise EngineError("not connected; call connect() first")
130
+ return self._conn
131
+
132
+ def _explain(self, exc: Exception) -> str:
133
+ """The driver's message, plus the one sentence it cannot know to add.
134
+
135
+ Measured: cut the connection under a live session and the first failing call reports what
136
+ the server said ("terminating connection due to administrator command"), which is right.
137
+ **Every call after it reports "the connection is closed"** - true, unhelpful, and the point
138
+ at which a reader needs to be told that this library holds the connection it was handed and
139
+ does not reopen it. Reconnecting is one line and it is the caller's, because a library that
140
+ silently reconnected would also silently retry, and requirement 6.4 allows a retry only for
141
+ an operation known to be idempotent.
142
+ """
143
+ message = str(exc)
144
+ if self._conn is not None and getattr(self._conn, "closed", False):
145
+ message += (
146
+ ". The connection is gone and this library does not reopen one it was handed: "
147
+ "call close() then connect() on the engine, or hand the session a new one. "
148
+ "Nothing was retried, so no write reached the engine twice."
149
+ )
150
+ return message
151
+
152
+ def write_fence(self, table: str, *, project_id: str) -> WriteFence:
153
+ """DDL capability; use a dedicated connection separate from application traffic."""
154
+ return WriteFence(PostgresFences(self._cx), table, project_id=project_id)
155
+
156
+ # --- schema ----------------------------------------------------------------------------
157
+
158
+ @guarded
159
+ def ensure_schema(
160
+ self, layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]]
161
+ ) -> tuple[PhysicalFinding, ...]:
162
+ """Create what is missing, change nothing that exists.
163
+
164
+ Idempotent on purpose: an application restarting must not reapply DDL, and two instances
165
+ starting at once must not race. Anything beyond creation - altering a column, dropping an
166
+ index - is a migration, which is the orchestrator's job and carries a safety
167
+ classification. A library that quietly altered a live column would be doing the one thing
168
+ this product promises never to do without a rollback path.
169
+ """
170
+ tables = [
171
+ statement
172
+ for statement in schema_statements(layout, keys=keys, dialect=self.dialect)
173
+ if statement.startswith("CREATE TABLE ")
174
+ ]
175
+ self._execute(tables)
176
+ self._verify_schema(layout)
177
+ # Indexes only on tables whose key is the declared one. A table with another primary key
178
+ # belongs to another design - an older map, or somebody else - and this map's refusal is
179
+ # coming. `CREATE INDEX` without CONCURRENTLY blocks that table's writes while it builds, so
180
+ # running it first would be a refused operation that still stopped the client's writes.
181
+ blocked = {
182
+ finding.table
183
+ for finding in self._physical_findings(layout, keys)
184
+ if finding.aspect == "primary key"
185
+ }
186
+ applicable = replace(
187
+ layout,
188
+ indexes=tuple(
189
+ index
190
+ for index in layout.indexes
191
+ if layout.tables.get(str(index["entity"])) not in blocked
192
+ ),
193
+ )
194
+ indexes = [
195
+ statement
196
+ for statement in schema_statements(applicable, keys=keys, dialect=self.dialect)
197
+ if statement.startswith("CREATE INDEX ")
198
+ ]
199
+ self._execute(indexes)
200
+ log("sde.schema.applied", engine=self.dialect, statements=len(tables) + len(indexes))
201
+ return self._physical_findings(layout, keys)
202
+
203
+ def _execute(self, statements: Sequence[str]) -> None:
204
+ with self._cx.cursor() as cur:
205
+ for statement in statements:
206
+ try:
207
+ cur.execute(statement)
208
+ except Exception as exc:
209
+ raise EngineError(f"schema statement failed: {statement}: {exc}") from exc
210
+
211
+ @guarded
212
+ def validate_schema(
213
+ self, layout: PhysicalLayout, *, keys: Mapping[str, Sequence[str]] | None = None
214
+ ) -> tuple[PhysicalFinding, ...]:
215
+ """Check the existing physical columns without issuing DDL; report the physical design."""
216
+ self._verify_schema(layout)
217
+ return () if keys is None else self._physical_findings(layout, keys)
218
+
219
+ def _physical_findings(
220
+ self, layout: PhysicalLayout, keys: Mapping[str, Sequence[str]]
221
+ ) -> tuple[PhysicalFinding, ...]:
222
+ """Primary key order and each declared index's method and columns, from ``pg_index``.
223
+
224
+ ``CREATE INDEX IF NOT EXISTS ... USING brin`` keeps an existing B-tree of that name -
225
+ measured - so the method is read back rather than assumed from the statement that ran.
226
+ An index with a predicate, an expression or INCLUDE columns is not the index a layout
227
+ declares, however its name reads.
228
+
229
+ Nor is a unique one, which refuses writes a layout never refuses, or one that is not valid
230
+ and ready. The last is what an interrupted ``CREATE INDEX CONCURRENTLY`` leaves behind -
231
+ measured, 15.19: the index stays in the catalogue with ``indisvalid`` and ``indisready``
232
+ false, the planner never uses it, and ``CREATE INDEX CONCURRENTLY IF NOT EXISTS`` over it
233
+ succeeds with only a notice. Reading the name, method and columns alone reported such a
234
+ leftover as the declared index.
235
+ """
236
+ declared = declared_tables(layout, keys)
237
+ if not declared:
238
+ return ()
239
+ tables = [entry.table for entry in declared]
240
+ with self._cx.cursor() as cur:
241
+ cur.execute(
242
+ "SELECT t.relname, ic.relname, i.indisprimary, am.amname, "
243
+ "ARRAY(SELECT a.attname FROM unnest(i.indkey) WITH ORDINALITY k(num, pos) "
244
+ "JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.num "
245
+ "ORDER BY k.pos), "
246
+ "i.indpred IS NULL AND i.indexprs IS NULL AND i.indnkeyatts = i.indnatts, "
247
+ "i.indisunique, i.indisvalid AND i.indisready "
248
+ "FROM pg_index i JOIN pg_class ic ON ic.oid = i.indexrelid "
249
+ "JOIN pg_class t ON t.oid = i.indrelid JOIN pg_am am ON am.oid = ic.relam "
250
+ "JOIN pg_namespace n ON n.oid = t.relnamespace "
251
+ "WHERE n.nspname = current_schema() AND t.relname = ANY(%s)",
252
+ [tables],
253
+ )
254
+ rows = cur.fetchall()
255
+ primary: dict[str, tuple[str, ...]] = {}
256
+ indexes: dict[str, dict[str, tuple[str, tuple[str, ...], bool, bool, bool]]] = {}
257
+ for table, index, is_primary, method, columns, simple, unique, usable in rows:
258
+ names = tuple(str(column) for column in columns)
259
+ if is_primary:
260
+ primary[str(table)] = names
261
+ else:
262
+ indexes.setdefault(str(table), {})[str(index)] = (
263
+ str(method),
264
+ names,
265
+ bool(simple),
266
+ bool(unique),
267
+ bool(usable),
268
+ )
269
+ findings: list[PhysicalFinding] = []
270
+ for entry in declared:
271
+ found_key = primary.get(entry.table)
272
+ if found_key != entry.key:
273
+ findings.append(
274
+ PhysicalFinding(
275
+ entry.table,
276
+ "primary key",
277
+ repr(list(entry.key)),
278
+ "absent" if found_key is None else repr(list(found_key)),
279
+ )
280
+ )
281
+ for index in entry.indexes:
282
+ wanted = f"{index.method} on {list(index.columns)}"
283
+ got = indexes.get(entry.table, {}).get(index.name)
284
+ if got is None:
285
+ findings.append(
286
+ PhysicalFinding(entry.table, f"index {index.name}", wanted, "absent")
287
+ )
288
+ continue
289
+ method, columns, simple, unique, usable = got
290
+ if (
291
+ method != index.method
292
+ or columns != index.columns
293
+ or not simple
294
+ or unique
295
+ or not usable
296
+ ):
297
+ shape = "" if simple else " with a predicate, expression or INCLUDE"
298
+ if unique:
299
+ shape += ", unique"
300
+ if not usable:
301
+ shape += ", not valid (an unfinished concurrent build)"
302
+ findings.append(
303
+ PhysicalFinding(
304
+ entry.table,
305
+ f"index {index.name}",
306
+ wanted,
307
+ f"{method} on {list(columns)}{shape}",
308
+ )
309
+ )
310
+ return tuple(findings)
311
+
312
+ def _verify_schema(self, layout: PhysicalLayout) -> None:
313
+ """Check that what exists is what the map describes, because IF NOT EXISTS does not.
314
+
315
+ `CREATE TABLE IF NOT EXISTS` accepts a table of that name whatever shape it is in, so a
316
+ table left over from something else - an older map, another application, a hand-run
317
+ migration - is silently kept and the first insert fails with `column "at" does not exist`.
318
+ That error names a column and not the cause, and it arrives in the client's request path
319
+ rather than at startup.
320
+
321
+ A **missing** column is refused: writes through this map cannot work. An **extra** column
322
+ is logged and allowed - a client may have added one outside SDE, the map does not name it,
323
+ writes are unaffected, and refusing would make this library an obstacle to work it has no
324
+ opinion about.
325
+
326
+ **Types as well as names, since 7 September 2026, and the reason the earlier version
327
+ checked names only was a true statement about the wrong catalogue.** It read
328
+ `information_schema.data_type`, which reports `numeric` for a `numeric(8,2)` column, and
329
+ concluded that comparing types would report differences that are not differences.
330
+ `pg_catalog.format_type(atttypid, atttypmod)` reports the canonical type *with* its
331
+ modifier - measured against every type this library renders, eleven of thirteen come back
332
+ as the exact string we wrote, and the two that do not are the timestamp aliases, which the
333
+ server itself will resolve for us.
334
+
335
+ What that cost while it was names-only, measured: a table whose `at` column is **`text`**
336
+ where the map says `timestamptz` passed this check and was reported as a good schema.
337
+ """
338
+ expected = {
339
+ table: dict(layout.columns.get(entity, {}))
340
+ for entity, table in sorted(layout.tables.items())
341
+ }
342
+ if not expected:
343
+ return
344
+
345
+ with self._cx.cursor() as cur:
346
+ # `pg_attribute` rather than `information_schema`, for `format_type`: the canonical
347
+ # spelling *including* the modifier, which is the whole reason this can compare types.
348
+ cur.execute(
349
+ "SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod) "
350
+ "FROM pg_attribute a "
351
+ "JOIN pg_class c ON c.oid = a.attrelid "
352
+ "JOIN pg_namespace n ON n.oid = c.relnamespace "
353
+ "WHERE n.nspname = current_schema() AND c.relname = ANY(%s) "
354
+ "AND a.attnum > 0 AND NOT a.attisdropped",
355
+ [sorted(expected)],
356
+ )
357
+ found: dict[str, dict[str, str]] = {}
358
+ for table_name, column_name, column_type in cur.fetchall():
359
+ found.setdefault(str(table_name), {})[str(column_name)] = str(column_type)
360
+
361
+ for table, columns in sorted(expected.items()):
362
+ actual = found.get(table)
363
+ if actual is None:
364
+ raise EngineError(
365
+ f"{table!r} does not exist after applying the schema. The statement reported "
366
+ f"success, so this is a permissions or search_path problem rather than a bad "
367
+ f"map."
368
+ )
369
+ missing = sorted(set(columns) - set(actual))
370
+ if missing:
371
+ raise EngineError(
372
+ f"{table!r} already existed with a different shape: the map needs {missing} "
373
+ f"and the table has {sorted(actual)}. `CREATE TABLE IF NOT EXISTS` keeps "
374
+ f"whatever is there, so this table came from somewhere else - an older map, "
375
+ f"another application, a migration run by hand. Refusing here rather than at "
376
+ f"the first insert, which would fail in your request path with an error naming "
377
+ f"a column and not the cause."
378
+ )
379
+ for column, declared in sorted(columns.items()):
380
+ reported = actual[column]
381
+ if self._same_type(declared, reported):
382
+ continue
383
+ raise EngineError(
384
+ f"{table}.{column} is {reported!r} and this map declares it {declared!r}. "
385
+ f"`CREATE TABLE IF NOT EXISTS` keeps a table of that name whatever shape it "
386
+ f"is in, and this library never alters a column's type - so the table came "
387
+ f"from somewhere else, or from a map that rendered this column differently. "
388
+ f"Refusing rather than writing into it: a type that differs is either a write "
389
+ f"that fails in your request path or, worse, one that succeeds and hands the "
390
+ f"value back as something else."
391
+ )
392
+ extra = sorted(set(actual) - set(columns))
393
+ if extra:
394
+ log("sde.schema.extra_columns", table=table, columns=extra)
395
+
396
+ def _same_type(self, declared: str, reported: str) -> bool:
397
+ """Whether two PostgreSQL type spellings denote the same type. Asked of the server.
398
+
399
+ Literal first, because that is the answer for every type this library renders except the
400
+ two timestamps. When it fails, the server is asked - `to_regtype` resolves an alias to the
401
+ type it names, so `timestamptz` and `timestamp with time zone` come back equal without this
402
+ module holding a table of aliases that could fall behind the renderer.
403
+
404
+ **A modifier makes a literal mismatch a real one.** `to_regtype` discards modifiers, so
405
+ `numeric(12,2)` and `numeric(8,2)` would both resolve to `numeric` and a precision change
406
+ would read as agreement. That is the one difference this check exists to catch, so a
407
+ parenthesis on either side ends the question here.
408
+ """
409
+ if declared == reported:
410
+ return True
411
+ if "(" in declared or "(" in reported:
412
+ return False
413
+ with self._cx.cursor() as cur:
414
+ cur.execute("SELECT to_regtype(%s)::text, to_regtype(%s)::text", [declared, reported])
415
+ row = cur.fetchone()
416
+ return bool(row is not None and row[0] is not None and row[0] == row[1])
417
+
418
+ # --- data ------------------------------------------------------------------------------
419
+
420
+ @guarded
421
+ def explain_plan(self, sql: str) -> QueryPlan:
422
+ """Plan an analyst's query without running it, inside a read-only transaction.
423
+
424
+ Requirement 19.4. Two ``EXPLAIN``s in one transaction: ``FORMAT JSON`` for the numbers and
425
+ the node types, and the plain one for the text a person reads - the engine's own rendering
426
+ rather than mine, because a plan reformatted by us is a plan whose wording somebody will
427
+ compare against the documentation and not find.
428
+
429
+ ``SET TRANSACTION READ ONLY``, and a **corrected** account of what it is for. The first
430
+ version of this docstring said planning could reach a write, because constant folding
431
+ evaluates immutable functions - ``EXPLAIN SELECT 1/0`` raises at plan time - so an
432
+ immutable function that lied about being immutable would run. Measured: it cannot.
433
+ PostgreSQL refuses the write itself, with *"INSERT is not allowed in a non-volatile
434
+ function"*, in a read-only transaction and outside one alike. And a ``VOLATILE`` function
435
+ is not folded at all. So **planning cannot write, and the read-only transaction guards
436
+ against nothing reachable through ``EXPLAIN`` today.**
437
+
438
+ It is kept, and the reason is an edit inside this block rather than a query outside it. One
439
+ word - ``ANALYZE`` - turns either statement below into an execution, and with the
440
+ transaction read-only that mistake fails loudly instead of running an analyst's
441
+ data-modifying CTE. A guard whose present hazard is zero and whose future hazard is one
442
+ keyword is worth a statement.
443
+
444
+ ``force_rollback=True`` has **no observable effect through EXPLAIN** and is kept for the
445
+ same reason one level along: it covers what read-only does not - a temporary table, a
446
+ ``SET LOCAL``, an advisory lock - none of which anything here creates today. Said plainly
447
+ rather than counted as a tested guarantee, because a mutation of it changes no output and
448
+ a mutation that changes no output is not one.
449
+
450
+ What gets reported is the transaction, not a claim about the query: this library has no SQL
451
+ parser and cannot say it checked the SQL.
452
+ """
453
+ with self._cx.transaction(force_rollback=True) as _tx, self._cx.cursor() as cursor:
454
+ cursor.execute("SET TRANSACTION READ ONLY")
455
+ # Read it back, because a statement that says nothing does not mean something
456
+ # happened - the lesson three GitHub API endpoints taught this project by returning
457
+ # 200 and changing nothing. It also turns this guard from unverifiable into checked:
458
+ # without the read-back, deleting the line above changes no observable behaviour, so
459
+ # nothing could fail when somebody did.
460
+ cursor.execute("SHOW transaction_read_only")
461
+ state = cursor.fetchone()
462
+ if not state or str(state[0]).strip().lower() != "on":
463
+ raise EngineError(
464
+ f"this connection would not go read-only for planning: "
465
+ f"transaction_read_only is {state[0] if state else 'unreadable'!r}. Refusing "
466
+ f"to plan anything: requirement 19.2 keeps this product out of the data path, "
467
+ f"and the one keyword between EXPLAIN and execution is ANALYZE - measured, an "
468
+ f"EXPLAIN (ANALYZE) of a write runs in a read-write transaction and is "
469
+ f"refused in a read-only one."
470
+ )
471
+ try:
472
+ cursor.execute(f"EXPLAIN (FORMAT JSON) {sql}")
473
+ raw = cursor.fetchone()
474
+ cursor.execute(f"EXPLAIN {sql}")
475
+ text = tuple(str(row[0]) for row in cursor.fetchall())
476
+ except Exception as exc:
477
+ # The refusal *is* the validation. Re-raised with the engine's own wording,
478
+ # because the case requirement 19.4 exists for - a column our map says exists
479
+ # and this database says does not - is one where the engine names the column
480
+ # and any summary of ours would lose it.
481
+ raise QueryPlanRefused(
482
+ f"{self.dialect} would not plan this query: {exc} Nothing was executed. "
483
+ f"This is what validating against a live engine means: the control plane "
484
+ f"checked the query against the schema it authored, and this is the schema "
485
+ f"that exists."
486
+ ) from exc
487
+ if not raw or not raw[0]:
488
+ raise QueryPlanRefused(
489
+ f"{self.dialect} returned no plan for this query and did not say why"
490
+ )
491
+ node = dict(raw[0][0]).get("Plan") or {}
492
+ return QueryPlan(
493
+ engine=self.dialect,
494
+ dialect=self.dialect,
495
+ plan=text or ("(the engine returned an empty plan)",),
496
+ cost=Cost(
497
+ units="PostgreSQL planner cost units",
498
+ basis=(
499
+ "arbitrary units, not seconds and not rows. The figure depends on this "
500
+ "machine and on this database's planner settings (seq_page_cost, "
501
+ "random_page_cost, the cost constants), so it is comparable between two plans "
502
+ "on this database and meaningless against a number from anywhere else. "
503
+ "plan_rows is the planner's estimate of rows returned, from statistics that "
504
+ "are as fresh as the last ANALYZE."
505
+ ),
506
+ values={
507
+ "total_cost": str(node.get("Total Cost", "")),
508
+ "startup_cost": str(node.get("Startup Cost", "")),
509
+ "plan_rows": str(node.get("Plan Rows", "")),
510
+ "plan_width": str(node.get("Plan Width", "")),
511
+ "root_node": str(node.get("Node Type", "")),
512
+ },
513
+ ),
514
+ findings=postgres_findings(node),
515
+ read_only_enforced=(
516
+ "SET TRANSACTION READ ONLY, and the transaction is rolled back. PostgreSQL "
517
+ "refuses a write in such a transaction whatever the statement looks like - "
518
+ "measured: a data-modifying CTE fails with 'cannot execute SELECT in a read-only "
519
+ "transaction'."
520
+ ),
521
+ )
522
+
523
+ @guarded
524
+ def insert(self, table: str, values: Mapping[str, Any]) -> None:
525
+ if not values:
526
+ raise EngineError("nothing to insert")
527
+ cols = sorted(values)
528
+ placeholders = ", ".join(["%s"] * len(cols))
529
+ sql = (
530
+ f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
531
+ f"VALUES ({placeholders})"
532
+ )
533
+ try:
534
+ with self._cx.cursor() as cur:
535
+ cur.execute(sql, [values[c] for c in cols])
536
+ except Exception as exc:
537
+ # Surfaced, not swallowed and not rerouted. See the module docstring.
538
+ log("sde.write.failed", table=table, error=type(exc).__name__)
539
+ raise EngineError(f"insert into {table} failed: {self._explain(exc)}") from exc
540
+
541
+ @guarded
542
+ def insert_many(self, table: str, rows: Sequence[Mapping[str, Any]]) -> None:
543
+ """One ordinary INSERT; unlike copy_in, a conflicting key is an error."""
544
+ cols = batch_columns(rows)
545
+ if not cols:
546
+ return
547
+ from psycopg.types.json import Jsonb
548
+
549
+ placeholders = ", ".join(f"({', '.join(['%s'] * len(cols))})" for _ in rows)
550
+ sql = (
551
+ f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
552
+ f"VALUES {placeholders}"
553
+ )
554
+ params = [
555
+ Jsonb(row[c]) if isinstance(row[c], (dict, list)) else row[c]
556
+ for row in rows for c in cols
557
+ ]
558
+ try:
559
+ with self._cx.cursor() as cur:
560
+ cur.execute(sql, params)
561
+ except Exception as exc:
562
+ log("sde.write.failed", table=table, error=type(exc).__name__)
563
+ raise EngineError(f"batch insert into {table} failed: {self._explain(exc)}") from exc
564
+
565
+ @guarded
566
+ def get(self, table: str, key: Mapping[str, Any]) -> dict[str, Any] | None:
567
+ where = " AND ".join(f"{_quote(c)} = %s" for c in sorted(key))
568
+ sql = f"SELECT * FROM {_quote(table)} WHERE {where}"
569
+ try:
570
+ with self._cx.cursor() as cur:
571
+ cur.execute(sql, [key[c] for c in sorted(key)])
572
+ row = cur.fetchone()
573
+ if row is None:
574
+ return None
575
+ names = [d.name for d in cur.description or ()]
576
+ return dict(zip(names, row, strict=True))
577
+ except Exception as exc:
578
+ raise EngineError(f"select from {table} failed: {self._explain(exc)}") from exc
579
+
580
+ # --- rollback protection ------------------------------------------------------------------
581
+ #
582
+ # Two methods that satisfy `sde.watermark.WatermarkStore`, which is a separate optional
583
+ # protocol rather than part of `Engine`: adding these to `Engine` would break every adapter
584
+ # anybody has written, for a capability our own orderbook engine cannot provide.
585
+
586
+ @guarded
587
+ def map_watermark(self) -> int | None:
588
+ """The highest map version applied against this engine, creating the table if missing.
589
+
590
+ Existing bookkeeping needs only read privileges. CREATE IF NOT EXISTS still checks schema
591
+ CREATE permission even when the table exists, so test existence through the catalog first.
592
+ Lazy creation remains available for legacy callers; prepare_schema creates signed-map
593
+ bookkeeping with the operator connection before restricted runtime credentials are used.
594
+ """
595
+ try:
596
+ with self._cx.cursor() as cur:
597
+ cur.execute("SELECT to_regclass(%s)", [WATERMARK_TABLE])
598
+ existing = cur.fetchone()
599
+ if existing is None:
600
+ raise EngineError("watermark catalog lookup returned no result")
601
+ if existing[0] is None:
602
+ cur.execute(
603
+ f"CREATE TABLE IF NOT EXISTS {_quote(WATERMARK_TABLE)} ("
604
+ f"{_quote('map_version')} bigint NOT NULL, "
605
+ f"{_quote('model_version')} text NOT NULL, "
606
+ f"{_quote('seen_at')} timestamptz NOT NULL DEFAULT now())"
607
+ )
608
+ cur.execute(f"SELECT max({_quote('map_version')}) FROM {_quote(WATERMARK_TABLE)}")
609
+ row = cur.fetchone()
610
+ except Exception as exc:
611
+ raise EngineError(f"reading {WATERMARK_TABLE} failed: {self._explain(exc)}") from exc
612
+ if row is None or row[0] is None:
613
+ return None
614
+ return int(row[0])
615
+
616
+ @guarded
617
+ def record_map_version(self, version: int, *, model_version: str) -> None:
618
+ """Append. Never update, so there is nothing to contend over and nothing to lose.
619
+
620
+ The timestamp comes from the engine's own `now()` rather than from this process: an audit
621
+ column wants the clock of the thing being audited, and this library reading a clock is a
622
+ thing its tests would then have to work around.
623
+ """
624
+ try:
625
+ with self._cx.cursor() as cur:
626
+ cur.execute(
627
+ f"INSERT INTO {_quote(WATERMARK_TABLE)} "
628
+ f"({_quote('map_version')}, {_quote('model_version')}) VALUES (%s, %s)",
629
+ [version, model_version],
630
+ )
631
+ except Exception as exc:
632
+ raise EngineError(
633
+ f"recording a map version in {WATERMARK_TABLE} failed: {exc}"
634
+ ) from exc
635
+
636
+ @guarded
637
+ def select_rows(self, table: str, plan: ReadPlan) -> list[dict[str, Any]]:
638
+ params: list[Any] = []
639
+ def parameter(value: Any) -> str:
640
+ params.append(value)
641
+ return "%s"
642
+ statement = read_sql(table, plan, dialect=self.dialect, parameter=parameter)
643
+ try:
644
+ with self._cx.cursor() as cursor:
645
+ cursor.execute(statement, params)
646
+ names = [column.name for column in cursor.description or ()]
647
+ return [read_row(plan.columns, dict(zip(names, row, strict=True)))
648
+ for row in cursor.fetchall()]
649
+ except Exception as exc:
650
+ raise EngineError(f"logical scan of {table} failed: {self._explain(exc)}") from exc
651
+
652
+ @guarded
653
+ def count_rows(self, table: str, plan: ReadPlan) -> int:
654
+ params: list[Any] = []
655
+ def parameter(value: Any) -> str:
656
+ params.append(value)
657
+ return "%s"
658
+ statement = read_sql(table, plan, dialect=self.dialect, parameter=parameter, count=True)
659
+ try:
660
+ with self._cx.cursor() as cursor:
661
+ cursor.execute(statement, params)
662
+ row = cursor.fetchone()
663
+ if row is None:
664
+ raise EngineError("count query returned no result")
665
+ return int(row[0])
666
+ except Exception as exc:
667
+ raise EngineError(f"logical count of {table} failed: {self._explain(exc)}") from exc
668
+
669
+ @guarded
670
+ def summarize_rows(
671
+ self, table: str, plan: ReadPlan, column: ReadColumn,
672
+ ) -> Mapping[str, Any]:
673
+ params: list[Any] = []
674
+ def parameter(value: Any) -> str:
675
+ params.append(value)
676
+ return "%s"
677
+ statement = summary_sql(table, plan, column, dialect=self.dialect, parameter=parameter)
678
+ try:
679
+ with self._cx.cursor() as cursor:
680
+ cursor.execute(statement, params)
681
+ names = [item.name for item in cursor.description or ()]
682
+ row = cursor.fetchone()
683
+ if row is None:
684
+ raise EngineError("summary query returned no result")
685
+ return dict(zip(names, row, strict=True))
686
+ except Exception as exc:
687
+ raise EngineError(f"logical summary of {table} failed: {self._explain(exc)}") from exc
688
+
689
+ @guarded
690
+ def range(
691
+ self,
692
+ table: str,
693
+ column: str,
694
+ *,
695
+ low: Any = None,
696
+ high: Any = None,
697
+ limit: int | None = None,
698
+ ) -> list[dict[str, Any]]:
699
+ clauses: list[str] = []
700
+ params: list[Any] = []
701
+ if low is not None:
702
+ clauses.append(f"{_quote(column)} >= %s")
703
+ params.append(low)
704
+ if high is not None:
705
+ clauses.append(f"{_quote(column)} < %s")
706
+ params.append(high)
707
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
708
+ order = f" ORDER BY {_quote(column)}"
709
+ cap = ""
710
+ if limit is not None:
711
+ cap = " LIMIT %s"
712
+ params.append(limit)
713
+ sql = f"SELECT * FROM {_quote(table)}{where}{order}{cap}"
714
+ try:
715
+ with self._cx.cursor() as cur:
716
+ cur.execute(sql, params)
717
+ names = [d.name for d in cur.description or ()]
718
+ return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
719
+ except Exception as exc:
720
+ raise EngineError(f"range select from {table} failed: {exc}") from exc
721
+
722
+ @guarded
723
+ def storage_sizes(self, tables: Sequence[str]) -> dict[str, tuple[int, int]]:
724
+ """Each table's bytes and its secondary index bytes, from the catalogue. Numbers only.
725
+
726
+ ``pg_total_relation_size`` - the table, its TOAST and every index - and the indexes other
727
+ than the primary key, one statement for every table named. A name the connection does not
728
+ resolve is absent from the answer rather than a zero: a missing table is not an empty one.
729
+ A login with nothing but SELECT and INSERT on the table reads the same numbers as the
730
+ administrator (measured on PostgreSQL 15), so this needs no grant of its own.
731
+ """
732
+ if not tables:
733
+ return {}
734
+ sql = (
735
+ "SELECT t.name, pg_total_relation_size(r.oid), "
736
+ "COALESCE((SELECT sum(pg_relation_size(i.indexrelid)) FROM pg_index i "
737
+ "WHERE i.indrelid = r.oid AND NOT i.indisprimary), 0) "
738
+ "FROM unnest(%s::text[]) AS t(name) "
739
+ "JOIN pg_class r ON r.oid = to_regclass(quote_ident(t.name))"
740
+ )
741
+ try:
742
+ with self._cx.cursor() as cursor:
743
+ cursor.execute(sql, [list(tables)])
744
+ return {
745
+ str(name): (int(total), int(secondary))
746
+ for name, total, secondary in cursor.fetchall()
747
+ }
748
+ except Exception as exc:
749
+ raise EngineError(f"storage sizes could not be read: {self._explain(exc)}") from exc
750
+
751
+ @guarded
752
+ def count(self, table: str) -> int:
753
+ try:
754
+ with self._cx.cursor() as cur:
755
+ cur.execute(f"SELECT count(*) FROM {_quote(table)}")
756
+ row = cur.fetchone()
757
+ return int(row[0]) if row else 0
758
+ except Exception as exc:
759
+ raise EngineError(f"count on {table} failed: {exc}") from exc
760
+
761
+ # --- migration ------------------------------------------------------------------------------
762
+ #
763
+ # Five methods that satisfy `sde.migration.Migratable`, which like `WatermarkStore` is a
764
+ # separate optional protocol. Same reason: our own orderbook engine cannot offer any of them -
765
+ # its schema is fixed in its own source and it has nowhere to keep a marker - and an engine that
766
+ # cannot take part in a migration should be a named refusal rather than a broken adapter.
767
+
768
+ @guarded
769
+ def key_range(
770
+ self,
771
+ table: str,
772
+ order: Sequence[str],
773
+ *,
774
+ after: Sequence[Any] | None = None,
775
+ upto: Sequence[Any] | None = None,
776
+ limit: int | None = None,
777
+ ) -> list[dict[str, Any]]:
778
+ """Rows in key order, strictly after one key and up to another inclusive.
779
+
780
+ Row-value comparison - ``(a, b) > (%s, %s)`` - rather than a hand-rolled disjunction over
781
+ the key's columns. The disjunction is where composite-key pagination goes wrong, and it goes
782
+ wrong by skipping rows.
783
+
784
+ The bounds are asymmetric on purpose. ``after`` is exclusive because it is a resume point:
785
+ the row it names has been dealt with. ``upto`` is inclusive because it names the last row of
786
+ a chunk read from somewhere else, and that row is one this range has to include.
787
+ """
788
+ cols = key_columns(order, table)
789
+ clauses: list[str] = []
790
+ params: list[Any] = []
791
+ tuple_expr = f"({', '.join(_quote(c) for c in cols)})"
792
+ if after is not None:
793
+ same_width(after, cols, "after")
794
+ clauses.append(f"{tuple_expr} > ({', '.join(['%s'] * len(cols))})")
795
+ params.extend(after)
796
+ if upto is not None:
797
+ same_width(upto, cols, "upto")
798
+ clauses.append(f"{tuple_expr} <= ({', '.join(['%s'] * len(cols))})")
799
+ params.extend(upto)
800
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
801
+ cap = ""
802
+ if limit is not None:
803
+ cap = " LIMIT %s"
804
+ params.append(int(limit))
805
+ sql = f"SELECT * FROM {_quote(table)}{where} ORDER BY {tuple_expr}{cap}"
806
+ try:
807
+ with self._cx.cursor() as cur:
808
+ cur.execute(sql, params)
809
+ names = [d.name for d in cur.description or ()]
810
+ return [dict(zip(names, row, strict=True)) for row in cur.fetchall()]
811
+ except Exception as exc:
812
+ raise EngineError(f"key range select from {table} failed: {exc}") from exc
813
+
814
+ @guarded
815
+ def nth_key(self, table: str, order: Sequence[str], *, position: int) -> tuple[Any, ...] | None:
816
+ """The key of the ``position``-th row in key order, one-based, or None if there is no such
817
+ row.
818
+
819
+ An ``OFFSET`` scan, which is the expensive kind of query, and it is here because it is paid
820
+ **once per resume** rather than once per chunk. See :mod:`sde.migration` for why the marker
821
+ is a row count and not a key.
822
+ """
823
+ cols = key_columns(order, table)
824
+ if position < 1:
825
+ raise EngineError(f"position is one-based; {position} is not a row")
826
+ projection = ", ".join(_quote(c) for c in cols)
827
+ sql = f"SELECT {projection} FROM {_quote(table)} ORDER BY ({projection}) OFFSET %s LIMIT 1"
828
+ try:
829
+ with self._cx.cursor() as cur:
830
+ cur.execute(sql, [position - 1])
831
+ row = cur.fetchone()
832
+ except Exception as exc:
833
+ raise EngineError(f"reading row {position} of {table} failed: {exc}") from exc
834
+ return None if row is None else tuple(row)
835
+
836
+ @guarded
837
+ def copy_in(self, table: str, rows: Sequence[Mapping[str, Any]]) -> None:
838
+ """Insert rows, skipping any whose key is already there.
839
+
840
+ ``ON CONFLICT DO NOTHING`` is what makes a backfill chunk **idempotent**, and idempotence is
841
+ what makes it resumable: the marker is written after the chunk, so a crash in between costs
842
+ a recopy and never a lost row. Without it the recopy would be a primary-key violation and
843
+ the safe failure mode would become the loud one.
844
+
845
+ The bare form, with no conflict target, so it covers the primary key and any unique index
846
+ the layout asked for. Naming the key here would mean deriving it a second time, and two
847
+ derivations of one key is how they come to disagree.
848
+
849
+ Returns nothing on purpose. PostgreSQL can say how many rows it actually wrote and
850
+ ClickHouse cannot, so a count here would mean different things in different engines - and
851
+ the caller needs "how much of the source have I consumed", which it already knows.
852
+ """
853
+ if not rows:
854
+ return
855
+ cols = sorted(rows[0])
856
+ for row in rows:
857
+ if sorted(row) != cols:
858
+ raise EngineError(
859
+ f"copy_in into {table} was given rows with different columns "
860
+ f"({cols} and {sorted(row)}). A chunk comes from one table, so this is a "
861
+ f"caller assembling it from two."
862
+ )
863
+ placeholders = ", ".join(f"({', '.join(['%s'] * len(cols))})" for _ in rows)
864
+ sql = (
865
+ f"INSERT INTO {_quote(table)} ({', '.join(_quote(c) for c in cols)}) "
866
+ f"VALUES {placeholders} ON CONFLICT DO NOTHING"
867
+ )
868
+ params: list[Any] = []
869
+ for row in rows:
870
+ params.extend(row[c] for c in cols)
871
+ try:
872
+ with self._cx.cursor() as cur:
873
+ cur.execute(sql, params)
874
+ except Exception as exc:
875
+ log("sde.write.failed", table=table, error=type(exc).__name__)
876
+ raise EngineError(f"copying {len(rows)} rows into {table} failed: {exc}") from exc
877
+
878
+ @guarded
879
+ def backfill_marker(self, *, materialization: str, entity: str) -> int:
880
+ """How many rows of this entity have been copied into this engine. Zero if none.
881
+
882
+ `max()` over an append-only table, exactly like the map watermark, and for the same reason:
883
+ no row to update, nothing to contend over, and identical semantics in an engine with no
884
+ unique constraint. A stale row can never lower the marker.
885
+ """
886
+ try:
887
+ with self._cx.cursor() as cur:
888
+ cur.execute(
889
+ f"CREATE TABLE IF NOT EXISTS {_quote(BACKFILL_TABLE)} ("
890
+ f"{_quote('materialization')} text NOT NULL, "
891
+ f"{_quote('entity')} text NOT NULL, "
892
+ f"{_quote('rows_copied')} bigint NOT NULL, "
893
+ f"{_quote('at')} timestamptz NOT NULL DEFAULT now())"
894
+ )
895
+ cur.execute(
896
+ f"SELECT max({_quote('rows_copied')}) FROM {_quote(BACKFILL_TABLE)} "
897
+ f"WHERE {_quote('materialization')} = %s AND {_quote('entity')} = %s",
898
+ [materialization, entity],
899
+ )
900
+ row = cur.fetchone()
901
+ except Exception as exc:
902
+ raise EngineError(f"reading {BACKFILL_TABLE} failed: {exc}") from exc
903
+ if row is None or row[0] is None:
904
+ return 0
905
+ return int(row[0])
906
+
907
+ @guarded
908
+ def record_backfill_marker(self, *, materialization: str, entity: str, rows: int) -> None:
909
+ """Append the new marker. Never update, so an interrupted run leaves a readable trail."""
910
+ try:
911
+ with self._cx.cursor() as cur:
912
+ cur.execute(
913
+ f"INSERT INTO {_quote(BACKFILL_TABLE)} ({_quote('materialization')}, "
914
+ f"{_quote('entity')}, {_quote('rows_copied')}) VALUES (%s, %s, %s)",
915
+ [materialization, entity, int(rows)],
916
+ )
917
+ except Exception as exc:
918
+ raise EngineError(
919
+ f"recording backfill progress in {BACKFILL_TABLE} failed: {exc}"
920
+ ) from exc
921
+
922
+ # --- transactions ----------------------------------------------------------------------
923
+
924
+ @contextmanager
925
+ def transaction(self) -> Iterator[PostgresEngine]:
926
+ """One engine, one transaction, that engine's semantics.
927
+
928
+ There is no distributed transaction here and there will not be one. A client needing two
929
+ entities to commit together declares that, and the planner puts them in the same group and
930
+ therefore the same engine - so the requirement turns into a placement constraint instead
931
+ of a two-phase commit. That is the trade this product makes, and it is why this method is
932
+ four lines rather than a subsystem.
933
+ """
934
+ with self._usage.transaction() as scope:
935
+ cx = self._cx
936
+ status = self._psycopg.pq.TransactionStatus
937
+ parent = scope.parent
938
+ nested = parent is not None and parent.gate is self._usage
939
+ expected = status.INTRANS if nested else status.IDLE
940
+ body_error: BaseException | None = None
941
+ try:
942
+ with cx.transaction():
943
+ try:
944
+ yield self
945
+ self._usage.idle()
946
+ if cx.info.transaction_status == status.INERROR:
947
+ raise EngineError(
948
+ "the transaction is aborted and cannot be reported as committed"
949
+ )
950
+ scope.active = False
951
+ except BaseException as exc:
952
+ scope.active = False
953
+ body_error = exc
954
+ raise
955
+ except BaseException as exc:
956
+ if body_error is None:
957
+ self._unusable = True
958
+ raise EngineError(
959
+ "transaction completion was uncertain; close() then connect() before reuse"
960
+ ) from exc
961
+ raise
962
+ finally:
963
+ try:
964
+ if cx.info.transaction_status != expected:
965
+ self._unusable = True
966
+ except Exception:
967
+ self._unusable = True