fleetmesh-node 0.2.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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- sidecar/fleetmesh_ssb.py +603 -0
node/storage/base.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""The backend-independent half of a store: the lock, the transaction, and the
|
|
2
|
+
declarative schema.
|
|
3
|
+
Bound to `SqliteDialect` by default. Everything here is backend-independent
|
|
4
|
+
and tested across supported backends.
|
|
5
|
+
|
|
6
|
+
Subclasses declare their schema rather than running DDL in ``__init__``:
|
|
7
|
+
|
|
8
|
+
- ``SCHEMA``: ``CREATE TABLE IF NOT EXISTS`` / ``CREATE INDEX IF NOT EXISTS``
|
|
9
|
+
statements, one per entry, executed in order on every open. They must be
|
|
10
|
+
idempotent, which the ``IF NOT EXISTS`` form guarantees.
|
|
11
|
+
- ``MIGRATIONS``: ``(version, sql)`` pairs, ascending. Each is applied once,
|
|
12
|
+
when the stored schema version is below ``version``; afterwards the version
|
|
13
|
+
is raised to the highest applied. A fresh database runs through every
|
|
14
|
+
migration too, so ``SCHEMA`` can stay the original DDL and a column added
|
|
15
|
+
later is expressed exactly once, as the ``ALTER TABLE`` that added it. A
|
|
16
|
+
database from before the version was tracked may already carry such a column
|
|
17
|
+
(the old open path added it under a ``has_column`` guard): a duplicate-column
|
|
18
|
+
error from an ``ADD COLUMN`` migration is therefore treated as "already
|
|
19
|
+
applied", which the dialect decides rather than a message match here.
|
|
20
|
+
- ``_post_schema()``: hook for data backfills that need Python, run inside the
|
|
21
|
+
same transaction after the migrations.
|
|
22
|
+
|
|
23
|
+
All of it runs in ``_ensure_schema()`` as one transaction at open, so a crash
|
|
24
|
+
midway leaves the database as it was.
|
|
25
|
+
|
|
26
|
+
``DURABILITY`` names what a store needs of its backend, for the three classes
|
|
27
|
+
in docs/storage-backends.md section 6:
|
|
28
|
+
|
|
29
|
+
'commit' a write is durable before the call returns
|
|
30
|
+
'window' may be batched, durable before the window closes or before a
|
|
31
|
+
receipt citing it leaves
|
|
32
|
+
'cache' may be lost; rebuildable from elsewhere
|
|
33
|
+
|
|
34
|
+
It is declared, not yet enforced: on SQLite every write is already a commit,
|
|
35
|
+
so the classes describe the contract a networked backend will have to honour.
|
|
36
|
+
I15 is the defect that exists because this was never named - see
|
|
37
|
+
docs/audit-2026-10-07-4-issues.md.
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import contextlib
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from typing import Any, Callable, Iterator, Optional, Sequence, TypedDict, Union
|
|
45
|
+
|
|
46
|
+
from ..compartment.schema import Migration
|
|
47
|
+
from .dialect import Dialect, SqliteDialect
|
|
48
|
+
from .locator import redact
|
|
49
|
+
|
|
50
|
+
LEASE_SECONDS: int = 900
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class StoreLease(TypedDict):
|
|
54
|
+
"""The lease as `meshnode health` reports it.
|
|
55
|
+
|
|
56
|
+
A named record rather than a loose mapping, because check-dicts.py holds
|
|
57
|
+
this tree to typing what it returns, and a row an operator reads by field
|
|
58
|
+
name is exactly the case that rule exists for.
|
|
59
|
+
"""
|
|
60
|
+
owner: str
|
|
61
|
+
expires_at: int
|
|
62
|
+
mine: bool
|
|
63
|
+
expires_in: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class StoreOwnedError(RuntimeError):
|
|
67
|
+
"""Another node holds a live lease on this database.
|
|
68
|
+
|
|
69
|
+
Raised by `open()` rather than letting two nodes write one database. One
|
|
70
|
+
node per database is a decision (docs/storage-backends.md header), and it
|
|
71
|
+
is a decision a connection string can break where a file could not:
|
|
72
|
+
`meters` is keyed (peer, epoch, side) and `grants` (peer, direction), and
|
|
73
|
+
neither includes the node, so two nodes would interleave rows, read each
|
|
74
|
+
other's accounting as their own, and price windows that never happened
|
|
75
|
+
with nothing in the schema complaining.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Cursor:
|
|
81
|
+
"""A cursor whose SQL goes through the dialect on the way out.
|
|
82
|
+
|
|
83
|
+
`transaction()` used to yield the driver's own cursor, so every
|
|
84
|
+
`cur.execute(...)` inside a `with store.transaction() as cur:` block
|
|
85
|
+
circumvented the translation the base exists to apply - 37 statements
|
|
86
|
+
across the tree, invisible while the SQLite dialect's `sql()` was the
|
|
87
|
+
identity function. Handing out something that bypasses your own interface
|
|
88
|
+
is the flaw; counting the call sites was the symptom.
|
|
89
|
+
|
|
90
|
+
Everything else - fetchone, fetchall, rowcount, description - passes
|
|
91
|
+
straight through to the real cursor.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
__slots__ = ('_cur', '_dialect')
|
|
95
|
+
|
|
96
|
+
def __init__(self, cur: Any, dialect: Dialect) -> None:
|
|
97
|
+
self._cur = cur
|
|
98
|
+
self._dialect = dialect
|
|
99
|
+
|
|
100
|
+
def execute(self, q: str, args: Sequence[Any] = ()) -> Any:
|
|
101
|
+
return self._cur.execute(self._dialect.sql(q), tuple(args))
|
|
102
|
+
|
|
103
|
+
def executemany(self, q: str, seq: Any) -> Any:
|
|
104
|
+
return self._cur.executemany(self._dialect.sql(q), seq)
|
|
105
|
+
|
|
106
|
+
def __iter__(self) -> Any:
|
|
107
|
+
return iter(self._cur)
|
|
108
|
+
|
|
109
|
+
def __getattr__(self, name: str) -> Any:
|
|
110
|
+
return getattr(self._cur, name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BaseStore:
|
|
114
|
+
"""Thread-safe persistence with a declarative schema, over any dialect."""
|
|
115
|
+
|
|
116
|
+
SCHEMA: tuple[Any, ...] = ()
|
|
117
|
+
MIGRATIONS: tuple[Union[Migration, tuple[int, str]], ...] = ()
|
|
118
|
+
DURABILITY: str = 'commit'
|
|
119
|
+
|
|
120
|
+
def __init__(self, path: str = ':memory:', timeout: float = 5.0,
|
|
121
|
+
dialect: Optional[Dialect] = None, owner: Optional[str] = None,
|
|
122
|
+
lease_seconds: int = LEASE_SECONDS,
|
|
123
|
+
clock: Optional[Callable[[], int]] = None,
|
|
124
|
+
readonly: bool = False,
|
|
125
|
+
schema: Optional[Sequence[Any]] = None) -> None:
|
|
126
|
+
self.path: str = path
|
|
127
|
+
self.dialect: Dialect = dialect or SqliteDialect()
|
|
128
|
+
self._lock: threading.RLock = threading.RLock()
|
|
129
|
+
self.owner: Optional[str] = owner
|
|
130
|
+
self.lease_seconds: int = int(lease_seconds)
|
|
131
|
+
self.clock: Callable[[], int] = clock or (lambda: int(time.time()))
|
|
132
|
+
self.readonly: bool = readonly
|
|
133
|
+
if schema is not None:
|
|
134
|
+
self.SCHEMA = tuple(schema)
|
|
135
|
+
self.db: Any = self.dialect.connect(path, timeout, readonly)
|
|
136
|
+
if not readonly:
|
|
137
|
+
self.dialect.tune(self.db, path, self._lock)
|
|
138
|
+
# Claimed before the schema runs: migrations are writes, and a
|
|
139
|
+
# database another node owns is not this node's to migrate.
|
|
140
|
+
if owner is not None:
|
|
141
|
+
self._claim_lease(owner)
|
|
142
|
+
self._ensure_schema()
|
|
143
|
+
|
|
144
|
+
# ---- One node per database ------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _claim_lease(self, owner: str) -> None:
|
|
147
|
+
"""Take the lease, or refuse because another node holds a live one.
|
|
148
|
+
|
|
149
|
+
A stale lease is takeable, which is what makes an ordinary restart
|
|
150
|
+
work: a node that died holds a lease nobody renews, and the next start
|
|
151
|
+
- its own or a replacement's - takes it once it expires.
|
|
152
|
+
"""
|
|
153
|
+
now = self.clock()
|
|
154
|
+
with self._lock:
|
|
155
|
+
self.db.execute(self.dialect.sql(
|
|
156
|
+
'CREATE TABLE IF NOT EXISTS fleetmesh_owner ('
|
|
157
|
+
' only_row INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL)'))
|
|
158
|
+
cur = self.db.execute(self.dialect.sql(
|
|
159
|
+
'SELECT owner, expires_at FROM fleetmesh_owner WHERE only_row = 1'))
|
|
160
|
+
try:
|
|
161
|
+
row = cur.fetchone()
|
|
162
|
+
finally:
|
|
163
|
+
cur.close()
|
|
164
|
+
if row is not None and row[0] != owner and int(row[1]) > now:
|
|
165
|
+
raise StoreOwnedError(
|
|
166
|
+
'database %s is held by node %s until %d (now %d); one node per database'
|
|
167
|
+
% (self.safe_path, str(row[0])[:16], int(row[1]), now))
|
|
168
|
+
self.db.execute(self.dialect.sql(
|
|
169
|
+
'INSERT OR REPLACE INTO fleetmesh_owner VALUES (1, ?, ?)'),
|
|
170
|
+
(owner, now + self.lease_seconds))
|
|
171
|
+
self.db.commit()
|
|
172
|
+
|
|
173
|
+
def renew_lease(self) -> None:
|
|
174
|
+
"""Push the lease out. The tick calls this; a node that stops renewing
|
|
175
|
+
releases the database to its successor once the lease runs out."""
|
|
176
|
+
if self.owner is None:
|
|
177
|
+
return
|
|
178
|
+
with self._lock:
|
|
179
|
+
self.db.execute(self.dialect.sql(
|
|
180
|
+
'INSERT OR REPLACE INTO fleetmesh_owner VALUES (1, ?, ?)'),
|
|
181
|
+
(self.owner, self.clock() + self.lease_seconds))
|
|
182
|
+
self.db.commit()
|
|
183
|
+
|
|
184
|
+
def lease(self) -> Optional[StoreLease]:
|
|
185
|
+
"""The lease as health should report it, or None when none is held."""
|
|
186
|
+
with self._lock:
|
|
187
|
+
if not self.dialect.has_table(self.db, 'fleetmesh_owner'):
|
|
188
|
+
return None
|
|
189
|
+
cur = self.db.execute(self.dialect.sql(
|
|
190
|
+
'SELECT owner, expires_at FROM fleetmesh_owner WHERE only_row = 1'))
|
|
191
|
+
try:
|
|
192
|
+
row = cur.fetchone()
|
|
193
|
+
finally:
|
|
194
|
+
cur.close()
|
|
195
|
+
if row is None:
|
|
196
|
+
return None
|
|
197
|
+
return StoreLease(owner=str(row[0]), expires_at=int(row[1]),
|
|
198
|
+
mine=row[0] == self.owner,
|
|
199
|
+
expires_in=int(row[1]) - self.clock())
|
|
200
|
+
|
|
201
|
+
# ---- Declarative Schema -------------------------------------------------
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def safe_path(self) -> str:
|
|
205
|
+
"""`path` with any credentials removed, for a message or a log.
|
|
206
|
+
|
|
207
|
+
A SQLite target is a filename and comes back unchanged. A server
|
|
208
|
+
target is a connection string and carries a password, so anything an
|
|
209
|
+
operator reads - a refusal, a log line, a health block - must use
|
|
210
|
+
this and not `path`. The lease refusal below is the case that found
|
|
211
|
+
it: it is the one message a misconfigured fleet is guaranteed to
|
|
212
|
+
produce, and it printed the whole URL.
|
|
213
|
+
"""
|
|
214
|
+
return redact(self.path) if '://' in self.path else self.path
|
|
215
|
+
|
|
216
|
+
def schema_name(self) -> str:
|
|
217
|
+
"""How this store identifies itself to the dialect's version table.
|
|
218
|
+
|
|
219
|
+
One node per database means all fourteen stores share one database on a
|
|
220
|
+
server backend, where a single version row would make their separate
|
|
221
|
+
migration series collide.
|
|
222
|
+
"""
|
|
223
|
+
return type(self).__name__
|
|
224
|
+
|
|
225
|
+
def user_version(self) -> int:
|
|
226
|
+
"""This store's stored schema version (0 if it never migrated)."""
|
|
227
|
+
with self._lock:
|
|
228
|
+
return self.dialect.get_version(self.db, self.schema_name())
|
|
229
|
+
|
|
230
|
+
def _post_schema(self) -> None:
|
|
231
|
+
"""Data backfill hook, run inside the schema transaction. No-op by default."""
|
|
232
|
+
|
|
233
|
+
def _ensure_schema(self) -> None:
|
|
234
|
+
"""Apply ``SCHEMA`` then any pending ``MIGRATIONS`` in one transaction."""
|
|
235
|
+
if not self.SCHEMA and not self.MIGRATIONS:
|
|
236
|
+
return
|
|
237
|
+
with self._lock:
|
|
238
|
+
self.dialect.begin(self.db)
|
|
239
|
+
try:
|
|
240
|
+
for entry in self.SCHEMA:
|
|
241
|
+
if hasattr(entry, 'to_sql'):
|
|
242
|
+
sql_stmt = entry.to_sql(dialect=self.dialect.name)
|
|
243
|
+
if hasattr(self.dialect, 'learn_table'):
|
|
244
|
+
self.dialect.learn_table(entry)
|
|
245
|
+
self.db.execute(self.dialect.sql(sql_stmt))
|
|
246
|
+
if hasattr(entry, 'secondary_sql'):
|
|
247
|
+
for sec in entry.secondary_sql(dialect=self.dialect.name):
|
|
248
|
+
self.db.execute(self.dialect.sql(sec))
|
|
249
|
+
else:
|
|
250
|
+
self.db.execute(self.dialect.sql(entry))
|
|
251
|
+
current = self.dialect.get_version(self.db, self.schema_name())
|
|
252
|
+
target = current
|
|
253
|
+
for m in self.MIGRATIONS:
|
|
254
|
+
if isinstance(m, Migration):
|
|
255
|
+
version, sql = m.to_tuple()
|
|
256
|
+
else:
|
|
257
|
+
version, sql = m
|
|
258
|
+
if version <= current:
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
self.db.execute(self.dialect.sql(sql))
|
|
262
|
+
except BaseException as e:
|
|
263
|
+
if not self.dialect.is_duplicate_column(e):
|
|
264
|
+
raise
|
|
265
|
+
target = max(target, version)
|
|
266
|
+
if target != current:
|
|
267
|
+
self.dialect.set_version(self.db, self.schema_name(), target)
|
|
268
|
+
self._post_schema()
|
|
269
|
+
self.dialect.commit(self.db)
|
|
270
|
+
except BaseException:
|
|
271
|
+
self.dialect.rollback(self.db)
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
def integrity_check(self) -> Optional[str]:
|
|
275
|
+
"""None if the database is sound, else what is wrong with it."""
|
|
276
|
+
with self._lock:
|
|
277
|
+
return self.dialect.integrity_check(self.db)
|
|
278
|
+
|
|
279
|
+
def vacuum(self, pages: int = 50) -> int:
|
|
280
|
+
"""Reclaim freed space and refresh planner statistics, per the dialect."""
|
|
281
|
+
with self._lock:
|
|
282
|
+
return self.dialect.vacuum(self.db, pages)
|
|
283
|
+
|
|
284
|
+
def close(self) -> None:
|
|
285
|
+
"""Safely close the underlying database connection."""
|
|
286
|
+
db = getattr(self, 'db', None)
|
|
287
|
+
if db is None:
|
|
288
|
+
return
|
|
289
|
+
self.db = None
|
|
290
|
+
lock = getattr(self, '_lock', None)
|
|
291
|
+
if lock is not None:
|
|
292
|
+
try:
|
|
293
|
+
with lock:
|
|
294
|
+
try:
|
|
295
|
+
db.close()
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
except Exception:
|
|
299
|
+
try:
|
|
300
|
+
db.close()
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
else:
|
|
304
|
+
try:
|
|
305
|
+
db.close()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
def __del__(self) -> None:
|
|
310
|
+
try:
|
|
311
|
+
self.close()
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
# ---- Query Execution with Guaranteed Cursor Cleanup ---------------------
|
|
316
|
+
|
|
317
|
+
def _one(self, q: str, args: Sequence[Any] = ()) -> Optional[tuple[Any, ...]]:
|
|
318
|
+
"""Execute query and fetch one row, closing the cursor before releasing the lock.
|
|
319
|
+
|
|
320
|
+
Prevents InterfaceError/SQLITE_MISUSE from unfinalized cursors collected by GC.
|
|
321
|
+
"""
|
|
322
|
+
with self._lock:
|
|
323
|
+
cur = self.db.execute(self.dialect.sql(q), tuple(args))
|
|
324
|
+
try:
|
|
325
|
+
return cur.fetchone()
|
|
326
|
+
finally:
|
|
327
|
+
cur.close()
|
|
328
|
+
|
|
329
|
+
def _all(self, q: str, args: Sequence[Any] = ()) -> list[tuple[Any, ...]]:
|
|
330
|
+
"""Execute query and fetch all rows, closing the cursor before releasing the lock."""
|
|
331
|
+
with self._lock:
|
|
332
|
+
cur = self.db.execute(self.dialect.sql(q), tuple(args))
|
|
333
|
+
try:
|
|
334
|
+
return cur.fetchall()
|
|
335
|
+
finally:
|
|
336
|
+
cur.close()
|
|
337
|
+
|
|
338
|
+
def execute(self, q: str, args: Sequence[Any] = ()) -> Any:
|
|
339
|
+
"""Execute a single statement and return the cursor within the locked context."""
|
|
340
|
+
with self._lock:
|
|
341
|
+
return self.db.execute(self.dialect.sql(q), tuple(args))
|
|
342
|
+
|
|
343
|
+
def executescript(self, script: str) -> None:
|
|
344
|
+
"""Execute multiple SQL statements and commit."""
|
|
345
|
+
with self._lock:
|
|
346
|
+
self.db.executescript(self.dialect.sql(script))
|
|
347
|
+
self.db.commit()
|
|
348
|
+
|
|
349
|
+
def commit(self) -> None:
|
|
350
|
+
"""Commit active transaction under lock."""
|
|
351
|
+
with self._lock:
|
|
352
|
+
self.db.commit()
|
|
353
|
+
|
|
354
|
+
@contextlib.contextmanager
|
|
355
|
+
def transaction(self) -> Iterator[Any]:
|
|
356
|
+
"""Atomic block: commit on success, rollback on exception.
|
|
357
|
+
|
|
358
|
+
The retry a serializable backend would need belongs here and nowhere
|
|
359
|
+
else, which is what keeps CockroachDB a later addition rather than a
|
|
360
|
+
rewrite (docs/storage-backends.md section 5). None is built: SQLite
|
|
361
|
+
never raises a retryable error, and neither does a Postgres backend
|
|
362
|
+
that keeps this one-connection-behind-one-lock shape.
|
|
363
|
+
"""
|
|
364
|
+
with self._lock:
|
|
365
|
+
cur = self.db.cursor()
|
|
366
|
+
try:
|
|
367
|
+
yield _Cursor(cur, self.dialect)
|
|
368
|
+
self.db.commit()
|
|
369
|
+
except Exception:
|
|
370
|
+
self.db.rollback()
|
|
371
|
+
raise
|
|
372
|
+
finally:
|
|
373
|
+
cur.close()
|
|
374
|
+
|
|
375
|
+
# ---- Schema Reflection Helpers ------------------------------------------
|
|
376
|
+
|
|
377
|
+
def has_table(self, table_name: str) -> bool:
|
|
378
|
+
"""Return True if the specified table exists in the database."""
|
|
379
|
+
with self._lock:
|
|
380
|
+
return self.dialect.has_table(self.db, table_name)
|
|
381
|
+
|
|
382
|
+
def has_column(self, table_name: str, column_name: str) -> bool:
|
|
383
|
+
"""Return True if the column exists in the table schema."""
|
|
384
|
+
with self._lock:
|
|
385
|
+
return self.dialect.has_column(self.db, table_name, column_name)
|
node/storage/dialect.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""What differs between one SQL backend and another, and nothing else.
|
|
2
|
+
|
|
3
|
+
The thirteen stores on `BaseStore` declare their schema and write ordinary
|
|
4
|
+
SQL. Almost all of it is portable; this module holds the parts that are not,
|
|
5
|
+
so that a second backend is one class here rather than edits at 152 execute
|
|
6
|
+
sites. The census behind that number, and why the list is this short, is
|
|
7
|
+
docs/storage-backends.md section 4.
|
|
8
|
+
|
|
9
|
+
A dialect owns:
|
|
10
|
+
|
|
11
|
+
connect/tune opening the connection and the per-backend setup that
|
|
12
|
+
follows it (SQLite's PRAGMAs, and nothing on a server)
|
|
13
|
+
sql() rendering a statement written in the house dialect -
|
|
14
|
+
`?` placeholders and `INSERT OR REPLACE/IGNORE` - into
|
|
15
|
+
what this backend accepts. SQLite's is identity, which
|
|
16
|
+
is what makes the split provably behaviour-preserving.
|
|
17
|
+
schema version read and write it (SQLite: `PRAGMA user_version`)
|
|
18
|
+
reflection has_table / has_column
|
|
19
|
+
errors is_duplicate_column, so a migration that was already
|
|
20
|
+
applied is recognised without matching a message string
|
|
21
|
+
at the call site
|
|
22
|
+
maintenance vacuum, which means different things per backend
|
|
23
|
+
|
|
24
|
+
Nothing here knows what a meter or a grant is.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
import sqlite3
|
|
30
|
+
import threading
|
|
31
|
+
from typing import Any, Optional, Protocol
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ensure_incremental_vacuum(db: sqlite3.Connection, path: str,
|
|
35
|
+
max_bytes: int = 256 * 1024 * 1024) -> bool:
|
|
36
|
+
"""One-off migration of a pre-existing auto_vacuum=NONE database.
|
|
37
|
+
|
|
38
|
+
PRAGMA auto_vacuum only takes effect on an empty database; on one created
|
|
39
|
+
before incremental vacuum was introduced it is silently ignored, and
|
|
40
|
+
incremental_vacuum() then reclaims nothing forever. Switching needs a full
|
|
41
|
+
VACUUM, which rewrites the file once. Done at open, before other writers,
|
|
42
|
+
and skipped above ``max_bytes`` (override FLEETMESH_VACUUM_MIGRATE_MAX_BYTES)
|
|
43
|
+
so a very large store is never blocked at startup - operators run
|
|
44
|
+
``VACUUM`` by hand there. Returns True if the database was converted.
|
|
45
|
+
"""
|
|
46
|
+
if path == ':memory:' or path.startswith('file::memory:'):
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
if db.execute('PRAGMA auto_vacuum').fetchone()[0] == 2:
|
|
50
|
+
return False
|
|
51
|
+
if not db.execute("SELECT 1 FROM sqlite_master LIMIT 1").fetchone():
|
|
52
|
+
return False # empty: the pragma alone already applies
|
|
53
|
+
limit = int(os.environ.get('FLEETMESH_VACUUM_MIGRATE_MAX_BYTES', max_bytes))
|
|
54
|
+
size = os.path.getsize(path) if os.path.exists(path) else 0
|
|
55
|
+
if size > limit:
|
|
56
|
+
return False
|
|
57
|
+
db.execute('PRAGMA auto_vacuum=INCREMENTAL')
|
|
58
|
+
db.execute('VACUUM')
|
|
59
|
+
return db.execute('PRAGMA auto_vacuum').fetchone()[0] == 2
|
|
60
|
+
except (sqlite3.Error, OSError):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Dialect(Protocol):
|
|
65
|
+
"""The backend-specific half of a store. One implementation per backend."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
# The expression that gives rows in insertion order, or None where the
|
|
69
|
+
# backend has none. SQLite has `rowid`; PostgreSQL has nothing equivalent -
|
|
70
|
+
# `ctid` is a physical location, not insertion order, and is not stable
|
|
71
|
+
# across updates. A store that needs insertion order carries its own
|
|
72
|
+
# column and uses this only to backfill rows written before it did.
|
|
73
|
+
rowid_expr: Optional[str]
|
|
74
|
+
|
|
75
|
+
def connect(self, target: str, timeout: float, readonly: bool = False) -> Any: ...
|
|
76
|
+
def tune(self, db: Any, target: str, lock: threading.RLock) -> None: ...
|
|
77
|
+
def sql(self, statement: str) -> str: ...
|
|
78
|
+
def begin(self, db: Any) -> None: ...
|
|
79
|
+
def commit(self, db: Any) -> None: ...
|
|
80
|
+
def rollback(self, db: Any) -> None: ...
|
|
81
|
+
def get_version(self, db: Any, store: str) -> int: ...
|
|
82
|
+
def set_version(self, db: Any, store: str, version: int) -> None: ...
|
|
83
|
+
def has_table(self, db: Any, table: str) -> bool: ...
|
|
84
|
+
def has_column(self, db: Any, table: str, column: str) -> bool: ...
|
|
85
|
+
def is_duplicate_column(self, exc: BaseException) -> bool: ...
|
|
86
|
+
def vacuum(self, db: Any, pages: int) -> int: ...
|
|
87
|
+
def integrity_check(self, db: Any) -> Optional[str]: ...
|
|
88
|
+
def learn_table(self, table: Any) -> None: ...
|
|
89
|
+
def errors(self) -> tuple[type[BaseException], ...]: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SqliteDialect:
|
|
93
|
+
"""SQLite, which is the default and the only backend that needs no operator.
|
|
94
|
+
|
|
95
|
+
`sql()` is the identity function: the house dialect *is* SQLite's, so the
|
|
96
|
+
extraction of BaseStore from BaseSqliteStore cannot change a statement.
|
|
97
|
+
A second dialect is where the translation lives.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
name: str = 'sqlite'
|
|
101
|
+
rowid_expr: Optional[str] = 'rowid'
|
|
102
|
+
|
|
103
|
+
def learn_table(self, table: Any) -> None:
|
|
104
|
+
"""SQLite requires no reflection for upsert queries."""
|
|
105
|
+
|
|
106
|
+
def connect(self, target: str, timeout: float, readonly: bool = False) -> sqlite3.Connection:
|
|
107
|
+
if readonly and target != ':memory:' and not target.startswith('file:'):
|
|
108
|
+
# What the CLI has always done when it reads a store beside a
|
|
109
|
+
# running node: never write, never take the WAL writer lock, never
|
|
110
|
+
# create a table the node has not created yet.
|
|
111
|
+
return sqlite3.connect('file:%s?mode=ro' % target, uri=True,
|
|
112
|
+
timeout=timeout, check_same_thread=False)
|
|
113
|
+
return sqlite3.connect(target, timeout=timeout, check_same_thread=False)
|
|
114
|
+
|
|
115
|
+
def tune(self, db: sqlite3.Connection, target: str, lock: threading.RLock) -> None:
|
|
116
|
+
"""Apply high-concurrency PRAGMAs while holding the lock."""
|
|
117
|
+
with lock:
|
|
118
|
+
# WAL mode applies to file-backed databases; :memory: ignores it
|
|
119
|
+
if target != ':memory:' and not target.startswith('file::memory:'):
|
|
120
|
+
# auto_vacuum must precede journal_mode=WAL and table creation; on an
|
|
121
|
+
# existing auto_vacuum=NONE database it is a no-op (needs a one-off VACUUM).
|
|
122
|
+
try:
|
|
123
|
+
db.execute('PRAGMA auto_vacuum=INCREMENTAL')
|
|
124
|
+
except sqlite3.OperationalError:
|
|
125
|
+
pass
|
|
126
|
+
try:
|
|
127
|
+
db.execute('PRAGMA journal_mode=WAL')
|
|
128
|
+
except sqlite3.OperationalError:
|
|
129
|
+
pass
|
|
130
|
+
db.execute('PRAGMA busy_timeout=5000')
|
|
131
|
+
db.execute('PRAGMA synchronous=NORMAL')
|
|
132
|
+
db.execute('PRAGMA foreign_keys=ON')
|
|
133
|
+
ensure_incremental_vacuum(db, target)
|
|
134
|
+
|
|
135
|
+
def sql(self, statement: str) -> str:
|
|
136
|
+
return statement
|
|
137
|
+
|
|
138
|
+
def begin(self, db: sqlite3.Connection) -> None:
|
|
139
|
+
db.execute('BEGIN')
|
|
140
|
+
|
|
141
|
+
def commit(self, db: sqlite3.Connection) -> None:
|
|
142
|
+
db.execute('COMMIT')
|
|
143
|
+
|
|
144
|
+
def rollback(self, db: sqlite3.Connection) -> None:
|
|
145
|
+
db.execute('ROLLBACK')
|
|
146
|
+
|
|
147
|
+
def get_version(self, db: sqlite3.Connection, store: str) -> int:
|
|
148
|
+
# `store` is ignored: each SQLite store is its own file, so
|
|
149
|
+
# PRAGMA user_version is already per store. It matters on a backend
|
|
150
|
+
# where every store shares one database - see PostgresDialect.
|
|
151
|
+
cur = db.execute('PRAGMA user_version')
|
|
152
|
+
try:
|
|
153
|
+
row = cur.fetchone()
|
|
154
|
+
finally:
|
|
155
|
+
cur.close()
|
|
156
|
+
return int(row[0]) if row else 0
|
|
157
|
+
|
|
158
|
+
def set_version(self, db: sqlite3.Connection, store: str, version: int) -> None:
|
|
159
|
+
db.execute('PRAGMA user_version=%d' % int(version))
|
|
160
|
+
|
|
161
|
+
def has_table(self, db: sqlite3.Connection, table: str) -> bool:
|
|
162
|
+
cur = db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,))
|
|
163
|
+
try:
|
|
164
|
+
return cur.fetchone() is not None
|
|
165
|
+
finally:
|
|
166
|
+
cur.close()
|
|
167
|
+
|
|
168
|
+
def has_column(self, db: sqlite3.Connection, table: str, column: str) -> bool:
|
|
169
|
+
cur = db.execute('PRAGMA table_info(%s)' % table)
|
|
170
|
+
try:
|
|
171
|
+
return column in {r[1] for r in cur.fetchall()}
|
|
172
|
+
finally:
|
|
173
|
+
cur.close()
|
|
174
|
+
|
|
175
|
+
def is_duplicate_column(self, exc: BaseException) -> bool:
|
|
176
|
+
return isinstance(exc, sqlite3.OperationalError) and 'duplicate column name' in str(exc)
|
|
177
|
+
|
|
178
|
+
def vacuum(self, db: sqlite3.Connection, pages: int) -> int:
|
|
179
|
+
"""Incremental vacuum to reclaim freed pages, and optimize the planner."""
|
|
180
|
+
try:
|
|
181
|
+
mode = db.execute('PRAGMA auto_vacuum').fetchone()[0]
|
|
182
|
+
if mode != 2: # not INCREMENTAL: incremental_vacuum is a no-op
|
|
183
|
+
db.execute('PRAGMA optimize')
|
|
184
|
+
return 0
|
|
185
|
+
before = db.execute('PRAGMA freelist_count').fetchone()[0]
|
|
186
|
+
db.execute('PRAGMA incremental_vacuum(%d)' % int(pages)).fetchall()
|
|
187
|
+
after = db.execute('PRAGMA freelist_count').fetchone()[0]
|
|
188
|
+
db.execute('PRAGMA optimize')
|
|
189
|
+
return max(0, before - after)
|
|
190
|
+
except Exception:
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
def integrity_check(self, db: sqlite3.Connection) -> Optional[str]:
|
|
194
|
+
"""None if the database is sound, else what is wrong with it.
|
|
195
|
+
|
|
196
|
+
`PRAGMA quick_check` is SQLite's, and a file-integrity check is a
|
|
197
|
+
file-backed idea: a server backend answers for its own soundness and
|
|
198
|
+
returns None here rather than pretending to run this.
|
|
199
|
+
"""
|
|
200
|
+
cur = db.execute('PRAGMA quick_check')
|
|
201
|
+
try:
|
|
202
|
+
row = cur.fetchone()
|
|
203
|
+
finally:
|
|
204
|
+
cur.close()
|
|
205
|
+
verdict = str(row[0]) if row else 'no answer'
|
|
206
|
+
return None if verdict == 'ok' else verdict
|
|
207
|
+
|
|
208
|
+
def errors(self) -> tuple[type[BaseException], ...]:
|
|
209
|
+
return (sqlite3.Error,)
|