altcodepro-polydb-python 2.5.9__py3-none-any.whl → 2.5.11__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.
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/METADATA +1 -1
- altcodepro_polydb_python-2.5.11.dist-info/RECORD +74 -0
- polydb/PolyDB.py +1 -1
- polydb/__init__.py +25 -14
- polydb/adapters/AzureBlobStorageAdapter.py +2 -1
- polydb/adapters/AzureQueueAdapter.py +3 -5
- polydb/adapters/AzureTableStorageAdapter.py +27 -9
- polydb/adapters/BlockchainBlobAdapter.py +0 -1
- polydb/adapters/BlockchainFileAdapter.py +1 -1
- polydb/adapters/BlockchainKVAdapter.py +20 -5
- polydb/adapters/BlockchainQueueAdapter.py +0 -1
- polydb/adapters/DynamoDBAdapter.py +2 -6
- polydb/adapters/EFSAdapter.py +1 -1
- polydb/adapters/GCPFilestoreAdapter.py +1 -1
- polydb/adapters/GCPPubSubAdapter.py +2 -2
- polydb/adapters/GCPStorageAdapter.py +1 -1
- polydb/adapters/KafkaQueueAdapter.py +10 -16
- polydb/adapters/MongoDBAdapter.py +3 -5
- polydb/adapters/PostgreSQLAdapter.py +38 -13
- polydb/adapters/RabbitMQAdapter.py +2 -2
- polydb/adapters/S3Adapter.py +1 -3
- polydb/adapters/S3CompatibleAdapter.py +1 -1
- polydb/adapters/SQSAdapter.py +1 -2
- polydb/adapters/VercelBlobAdapter.py +1 -1
- polydb/adapters/VercelKVAdapter.py +6 -5
- polydb/adapters/VercelQueueAdapter.py +18 -4
- polydb/advanced_query.py +5 -4
- polydb/aio.py +198 -0
- polydb/audit/AuditStorage.py +14 -19
- polydb/audit/__init__.py +2 -2
- polydb/audit/context.py +6 -5
- polydb/audit/manager.py +3 -3
- polydb/audit/models.py +3 -2
- polydb/base/NoSQLKVAdapter.py +22 -14
- polydb/base/ObjectStorageAdapter.py +3 -2
- polydb/base/QueueAdapter.py +2 -3
- polydb/base/SecretsAdapter.py +2 -1
- polydb/base/SharedFilesAdapter.py +2 -3
- polydb/batch.py +38 -49
- polydb/cache.py +5 -2
- polydb/cloudDatabaseFactory.py +59 -22
- polydb/databaseFactory.py +7 -13
- polydb/errors.py +2 -0
- polydb/json_safe.py +1 -2
- polydb/models.py +1 -1
- polydb/monitoring.py +27 -15
- polydb/multitenancy.py +50 -59
- polydb/observability/logging.py +0 -1
- polydb/overflow_gc.py +179 -0
- polydb/query.py +3 -1
- polydb/retry.py +1 -1
- polydb/schema.py +45 -60
- polydb/security.py +7 -9
- polydb/services/__init__.py +2 -2
- polydb/services/compliance_service.py +10 -4
- polydb/services/security_service.py +9 -12
- polydb/types.py +28 -62
- polydb/utils.py +7 -8
- polydb/validation.py +50 -52
- altcodepro_polydb_python-2.5.9.dist-info/RECORD +0 -73
- polydb/decorators.py +0 -1040
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.5.9.dist-info → altcodepro_polydb_python-2.5.11.dist-info}/top_level.txt +0 -0
polydb/multitenancy.py
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Multi-tenancy enforcement and isolation
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import re
|
|
6
|
-
from typing import Dict, Any, List, Optional, Callable
|
|
7
7
|
from contextvars import ContextVar
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from enum import Enum
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
10
11
|
|
|
11
12
|
from .errors import ValidationError
|
|
12
13
|
|
|
@@ -42,6 +43,7 @@ def _validate_sql_identifier(value: str, *, kind: str) -> str:
|
|
|
42
43
|
|
|
43
44
|
class IsolationLevel(Enum):
|
|
44
45
|
"""Tenant isolation levels"""
|
|
46
|
+
|
|
45
47
|
SHARED_SCHEMA = "shared" # Shared tables with tenant_id
|
|
46
48
|
SEPARATE_SCHEMA = "schema" # Separate schema per tenant
|
|
47
49
|
SEPARATE_DATABASE = "database" # Separate DB per tenant
|
|
@@ -50,6 +52,7 @@ class IsolationLevel(Enum):
|
|
|
50
52
|
@dataclass
|
|
51
53
|
class TenantConfig:
|
|
52
54
|
"""Tenant configuration"""
|
|
55
|
+
|
|
53
56
|
tenant_id: str
|
|
54
57
|
isolation_level: IsolationLevel
|
|
55
58
|
schema_name: Optional[str] = None
|
|
@@ -61,18 +64,18 @@ class TenantConfig:
|
|
|
61
64
|
|
|
62
65
|
class TenantRegistry:
|
|
63
66
|
"""Registry of tenant configurations"""
|
|
64
|
-
|
|
67
|
+
|
|
65
68
|
def __init__(self):
|
|
66
69
|
self._tenants: Dict[str, TenantConfig] = {}
|
|
67
|
-
|
|
70
|
+
|
|
68
71
|
def register(self, config: TenantConfig):
|
|
69
72
|
"""Register tenant"""
|
|
70
73
|
self._tenants[config.tenant_id] = config
|
|
71
|
-
|
|
74
|
+
|
|
72
75
|
def get(self, tenant_id: str) -> Optional[TenantConfig]:
|
|
73
76
|
"""Get tenant config"""
|
|
74
77
|
return self._tenants.get(tenant_id)
|
|
75
|
-
|
|
78
|
+
|
|
76
79
|
def list_all(self) -> List[TenantConfig]:
|
|
77
80
|
"""List all tenants"""
|
|
78
81
|
return list(self._tenants.values())
|
|
@@ -80,24 +83,23 @@ class TenantRegistry:
|
|
|
80
83
|
|
|
81
84
|
class TenantContext:
|
|
82
85
|
"""Tenant context management"""
|
|
83
|
-
|
|
84
|
-
current_tenant: ContextVar[Optional[TenantConfig]] =
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
|
|
87
|
+
current_tenant: ContextVar[Optional[TenantConfig]] = ContextVar("current_tenant", default=None)
|
|
88
|
+
|
|
87
89
|
@classmethod
|
|
88
90
|
def set_tenant(cls, tenant_id: str, registry: TenantRegistry):
|
|
89
91
|
"""Set current tenant"""
|
|
90
92
|
config = registry.get(tenant_id)
|
|
91
93
|
if not config:
|
|
92
94
|
raise ValueError(f"Tenant not found: {tenant_id}")
|
|
93
|
-
|
|
95
|
+
|
|
94
96
|
cls.current_tenant.set(config)
|
|
95
|
-
|
|
97
|
+
|
|
96
98
|
@classmethod
|
|
97
99
|
def get_tenant(cls) -> Optional[TenantConfig]:
|
|
98
100
|
"""Get current tenant"""
|
|
99
101
|
return cls.current_tenant.get()
|
|
100
|
-
|
|
102
|
+
|
|
101
103
|
@classmethod
|
|
102
104
|
def clear(cls):
|
|
103
105
|
"""Clear tenant context"""
|
|
@@ -106,53 +108,45 @@ class TenantContext:
|
|
|
106
108
|
|
|
107
109
|
class TenantIsolationEnforcer:
|
|
108
110
|
"""Enforces tenant isolation at query level"""
|
|
109
|
-
|
|
111
|
+
|
|
110
112
|
def __init__(self, registry: TenantRegistry):
|
|
111
113
|
self.registry = registry
|
|
112
|
-
|
|
113
|
-
def enforce_read(
|
|
114
|
-
self,
|
|
115
|
-
model: str,
|
|
116
|
-
query: Dict[str, Any]
|
|
117
|
-
) -> Dict[str, Any]:
|
|
114
|
+
|
|
115
|
+
def enforce_read(self, model: str, query: Dict[str, Any]) -> Dict[str, Any]:
|
|
118
116
|
"""Enforce tenant isolation on read"""
|
|
119
117
|
tenant = TenantContext.get_tenant()
|
|
120
|
-
|
|
118
|
+
|
|
121
119
|
if not tenant:
|
|
122
120
|
raise ValueError("No tenant context set")
|
|
123
|
-
|
|
121
|
+
|
|
124
122
|
if tenant.isolation_level == IsolationLevel.SHARED_SCHEMA:
|
|
125
123
|
# Add tenant_id filter
|
|
126
124
|
query = query.copy()
|
|
127
|
-
query[
|
|
128
|
-
|
|
125
|
+
query["tenant_id"] = tenant.tenant_id
|
|
126
|
+
|
|
129
127
|
return query
|
|
130
|
-
|
|
131
|
-
def enforce_write(
|
|
132
|
-
self,
|
|
133
|
-
model: str,
|
|
134
|
-
data: Dict[str, Any]
|
|
135
|
-
) -> Dict[str, Any]:
|
|
128
|
+
|
|
129
|
+
def enforce_write(self, model: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
136
130
|
"""Enforce tenant isolation on write"""
|
|
137
131
|
tenant = TenantContext.get_tenant()
|
|
138
|
-
|
|
132
|
+
|
|
139
133
|
if not tenant:
|
|
140
134
|
raise ValueError("No tenant context set")
|
|
141
|
-
|
|
135
|
+
|
|
142
136
|
if tenant.isolation_level == IsolationLevel.SHARED_SCHEMA:
|
|
143
137
|
# Add tenant_id
|
|
144
138
|
data = data.copy()
|
|
145
|
-
data[
|
|
146
|
-
|
|
139
|
+
data["tenant_id"] = tenant.tenant_id
|
|
140
|
+
|
|
147
141
|
return data
|
|
148
|
-
|
|
142
|
+
|
|
149
143
|
def get_table_name(self, base_table: str) -> str:
|
|
150
144
|
"""Get tenant-specific table name"""
|
|
151
145
|
tenant = TenantContext.get_tenant()
|
|
152
|
-
|
|
146
|
+
|
|
153
147
|
if not tenant:
|
|
154
148
|
raise ValueError("No tenant context set")
|
|
155
|
-
|
|
149
|
+
|
|
156
150
|
if tenant.isolation_level == IsolationLevel.SEPARATE_SCHEMA:
|
|
157
151
|
return f"{tenant.schema_name}.{base_table}"
|
|
158
152
|
elif tenant.isolation_level == IsolationLevel.SEPARATE_DATABASE:
|
|
@@ -163,28 +157,29 @@ class TenantIsolationEnforcer:
|
|
|
163
157
|
|
|
164
158
|
class TenantQuotaManager:
|
|
165
159
|
"""Manages tenant resource quotas"""
|
|
166
|
-
|
|
160
|
+
|
|
167
161
|
def __init__(self, registry: TenantRegistry):
|
|
168
162
|
self.registry = registry
|
|
169
163
|
self._usage: Dict[str, Dict[str, float]] = {}
|
|
170
|
-
|
|
164
|
+
|
|
171
165
|
def check_storage_quota(self, tenant_id: str, size_gb: float) -> bool:
|
|
172
166
|
"""Check if operation would exceed storage quota"""
|
|
173
167
|
config = self.registry.get(tenant_id)
|
|
174
168
|
if not config or not config.storage_quota_gb:
|
|
175
169
|
return True
|
|
176
|
-
|
|
177
|
-
current_usage = self._usage.get(tenant_id, {}).get(
|
|
170
|
+
|
|
171
|
+
current_usage = self._usage.get(tenant_id, {}).get("storage_gb", 0.0)
|
|
178
172
|
return (current_usage + size_gb) <= config.storage_quota_gb
|
|
179
|
-
|
|
173
|
+
|
|
180
174
|
def record_storage_usage(self, tenant_id: str, size_gb: float):
|
|
181
175
|
"""Record storage usage"""
|
|
182
176
|
if tenant_id not in self._usage:
|
|
183
177
|
self._usage[tenant_id] = {}
|
|
184
|
-
|
|
185
|
-
self._usage[tenant_id][
|
|
186
|
-
self._usage[tenant_id].get(
|
|
187
|
-
|
|
178
|
+
|
|
179
|
+
self._usage[tenant_id]["storage_gb"] = (
|
|
180
|
+
self._usage[tenant_id].get("storage_gb", 0.0) + size_gb
|
|
181
|
+
)
|
|
182
|
+
|
|
188
183
|
def get_usage(self, tenant_id: str) -> Dict[str, float]:
|
|
189
184
|
"""Get tenant resource usage"""
|
|
190
185
|
return self._usage.get(tenant_id, {})
|
|
@@ -192,46 +187,42 @@ class TenantQuotaManager:
|
|
|
192
187
|
|
|
193
188
|
class TenantMigrationManager:
|
|
194
189
|
"""Manages tenant migrations and onboarding"""
|
|
195
|
-
|
|
190
|
+
|
|
196
191
|
def __init__(self, factory, registry: TenantRegistry):
|
|
197
192
|
self.factory = factory
|
|
198
193
|
self.registry = registry
|
|
199
|
-
|
|
194
|
+
|
|
200
195
|
def provision_tenant(self, config: TenantConfig):
|
|
201
196
|
"""Provision new tenant"""
|
|
202
197
|
# Register tenant
|
|
203
198
|
self.registry.register(config)
|
|
204
|
-
|
|
199
|
+
|
|
205
200
|
if config.isolation_level == IsolationLevel.SEPARATE_SCHEMA:
|
|
206
201
|
# Create schema
|
|
207
202
|
schema = _validate_sql_identifier(config.schema_name, kind="schema_name")
|
|
208
203
|
schema_sql = f"CREATE SCHEMA IF NOT EXISTS {schema};"
|
|
209
204
|
self.factory._sql.execute(schema_sql)
|
|
210
|
-
|
|
205
|
+
|
|
211
206
|
elif config.isolation_level == IsolationLevel.SEPARATE_DATABASE:
|
|
212
207
|
# Create database (requires superuser)
|
|
213
|
-
database = _validate_sql_identifier(
|
|
214
|
-
config.database_name, kind="database_name"
|
|
215
|
-
)
|
|
208
|
+
database = _validate_sql_identifier(config.database_name, kind="database_name")
|
|
216
209
|
db_sql = f"CREATE DATABASE {database};"
|
|
217
210
|
self.factory._sql.execute(db_sql)
|
|
218
|
-
|
|
211
|
+
|
|
219
212
|
def deprovision_tenant(self, tenant_id: str):
|
|
220
213
|
"""Deprovision tenant"""
|
|
221
214
|
config = self.registry.get(tenant_id)
|
|
222
215
|
if not config:
|
|
223
216
|
return
|
|
224
|
-
|
|
217
|
+
|
|
225
218
|
if config.isolation_level == IsolationLevel.SEPARATE_SCHEMA:
|
|
226
219
|
# Drop schema
|
|
227
220
|
schema = _validate_sql_identifier(config.schema_name, kind="schema_name")
|
|
228
221
|
schema_sql = f"DROP SCHEMA IF EXISTS {schema} CASCADE;"
|
|
229
222
|
self.factory._sql.execute(schema_sql)
|
|
230
|
-
|
|
223
|
+
|
|
231
224
|
elif config.isolation_level == IsolationLevel.SEPARATE_DATABASE:
|
|
232
225
|
# Drop database
|
|
233
|
-
database = _validate_sql_identifier(
|
|
234
|
-
config.database_name, kind="database_name"
|
|
235
|
-
)
|
|
226
|
+
database = _validate_sql_identifier(config.database_name, kind="database_name")
|
|
236
227
|
db_sql = f"DROP DATABASE IF EXISTS {database};"
|
|
237
|
-
self.factory._sql.execute(db_sql)
|
|
228
|
+
self.factory._sql.execute(db_sql)
|
polydb/observability/logging.py
CHANGED
polydb/overflow_gc.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
src/polydb/overflow_gc.py
|
|
3
|
+
|
|
4
|
+
Garbage collection for orphaned NoSQL KV overflow blobs.
|
|
5
|
+
|
|
6
|
+
Every `NoSQLKVAdapter` spills an oversized record's JSON to the paired
|
|
7
|
+
object store under a content-addressed key (``overflow/<md5>.json``) and
|
|
8
|
+
keeps only a small reference row (``_overflow``/``_blob_key``/``_size``/
|
|
9
|
+
``_checksum``) in the KV store itself (see
|
|
10
|
+
``NoSQLKVAdapter._check_overflow``). An update to an already-overflowed
|
|
11
|
+
record writes a *new* blob (a new md5 for the new content) and leaves the
|
|
12
|
+
old one behind with nothing referencing it any more -- ``delete()`` only
|
|
13
|
+
ever removes the reference row, never the blob it pointed at. Nothing in
|
|
14
|
+
this codebase has ever swept those orphans, so overflow storage grows
|
|
15
|
+
without bound for the life of a deployment (see CLAUDE.md's "Overflow
|
|
16
|
+
blobs are never garbage-collected").
|
|
17
|
+
|
|
18
|
+
This module closes that gap with a conservative two-pass mark-and-sweep:
|
|
19
|
+
a blob under the given prefix that isn't referenced by any live row is
|
|
20
|
+
only a *candidate* the first time it's seen -- it's recorded, with the
|
|
21
|
+
wall-clock time it was first seen, in a small JSON state blob written
|
|
22
|
+
back into the same object store (``<prefix>_gc_state.json``). A candidate
|
|
23
|
+
is only actually deleted once ``grace_seconds`` have elapsed since that
|
|
24
|
+
first sighting *and* it is still unreferenced on this run. This gives a
|
|
25
|
+
real grace window without needing last-modified timestamps from the
|
|
26
|
+
object store, which the ``ObjectStorageAdapter`` protocol does not
|
|
27
|
+
expose on any backend.
|
|
28
|
+
|
|
29
|
+
A blob that becomes referenced again between two sweeps (a genuine race
|
|
30
|
+
with a concurrent write, not merely a slow one -- content-addressed keys
|
|
31
|
+
mean a live row can only ever reference the blob matching its own
|
|
32
|
+
current content) drops out of the candidate list rather than being
|
|
33
|
+
deleted, so a sweep can never delete a blob a live row still points at.
|
|
34
|
+
|
|
35
|
+
PolyDB doesn't own a model registry (see databaseFactory.py's own "dumb
|
|
36
|
+
storage layer" design stance) -- the caller supplies the list of models
|
|
37
|
+
to scan, e.g.:
|
|
38
|
+
|
|
39
|
+
from polydb.overflow_gc import sweep_overflow_blobs
|
|
40
|
+
from polydb.cloudDatabaseFactory import CloudDatabaseFactory
|
|
41
|
+
|
|
42
|
+
factory = CloudDatabaseFactory()
|
|
43
|
+
report = sweep_overflow_blobs(
|
|
44
|
+
factory.get_object_storage(),
|
|
45
|
+
factory.get_nosql_kv(),
|
|
46
|
+
models=[Order, Widget],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
Run on a schedule (daily is plenty) -- a blob only gets deleted on the
|
|
50
|
+
*second* sweep that finds it orphaned, at least `grace_seconds` after the
|
|
51
|
+
first.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
import time
|
|
58
|
+
from dataclasses import dataclass, field
|
|
59
|
+
from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple
|
|
60
|
+
|
|
61
|
+
from .json_safe import json_safe
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING:
|
|
64
|
+
from .base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
65
|
+
from .base.ObjectStorageAdapter import ObjectStorageAdapter
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
DEFAULT_PREFIX = "overflow/"
|
|
69
|
+
DEFAULT_GRACE_SECONDS = 24 * 60 * 60 # 24h
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class GCReport:
|
|
74
|
+
"""Result of one sweep_overflow_blobs() call."""
|
|
75
|
+
|
|
76
|
+
scanned_blobs: int = 0
|
|
77
|
+
referenced_blobs: int = 0
|
|
78
|
+
new_candidates: List[str] = field(default_factory=list)
|
|
79
|
+
still_pending: List[str] = field(default_factory=list)
|
|
80
|
+
deleted: List[str] = field(default_factory=list)
|
|
81
|
+
errors: List[Tuple[str, str]] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _state_key(prefix: str) -> str:
|
|
85
|
+
return f"{prefix.rstrip('/')}/_gc_state.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_state(object_storage: "ObjectStorageAdapter", prefix: str) -> dict:
|
|
89
|
+
try:
|
|
90
|
+
raw = object_storage.get(_state_key(prefix))
|
|
91
|
+
except Exception:
|
|
92
|
+
return {}
|
|
93
|
+
if not raw:
|
|
94
|
+
return {}
|
|
95
|
+
try:
|
|
96
|
+
return json.loads(raw.decode() if isinstance(raw, bytes) else raw)
|
|
97
|
+
except Exception:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _save_state(object_storage: "ObjectStorageAdapter", prefix: str, state: dict) -> None:
|
|
102
|
+
object_storage.put(_state_key(prefix), json.dumps(state, default=json_safe).encode())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _referenced_blob_keys(adapter: "NoSQLKVAdapter", models: Iterable[type]) -> set:
|
|
106
|
+
"""Every _blob_key a live row currently points at, across the given models."""
|
|
107
|
+
referenced: set = set()
|
|
108
|
+
for model in models:
|
|
109
|
+
for row in adapter._query_raw(model, {}, None):
|
|
110
|
+
blob_key = row.get("_blob_key")
|
|
111
|
+
if blob_key:
|
|
112
|
+
referenced.add(blob_key)
|
|
113
|
+
return referenced
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def sweep_overflow_blobs(
|
|
117
|
+
object_storage: "ObjectStorageAdapter",
|
|
118
|
+
adapter: "NoSQLKVAdapter",
|
|
119
|
+
models: Iterable[type],
|
|
120
|
+
*,
|
|
121
|
+
prefix: str = DEFAULT_PREFIX,
|
|
122
|
+
grace_seconds: int = DEFAULT_GRACE_SECONDS,
|
|
123
|
+
dry_run: bool = False,
|
|
124
|
+
now: Optional[float] = None,
|
|
125
|
+
) -> GCReport:
|
|
126
|
+
"""
|
|
127
|
+
Run one GC pass over `prefix` in `object_storage`, treating every
|
|
128
|
+
`_blob_key` referenced by a live row of `models` (read through
|
|
129
|
+
`adapter`) as live. Anything else is orphaned; an orphan is only
|
|
130
|
+
deleted once it has been orphaned for at least `grace_seconds` across
|
|
131
|
+
two separate sweeps. `dry_run=True` reports what *would* be deleted
|
|
132
|
+
without deleting anything or advancing the grace clock.
|
|
133
|
+
"""
|
|
134
|
+
now = time.time() if now is None else now
|
|
135
|
+
report = GCReport()
|
|
136
|
+
|
|
137
|
+
state_key_name = _state_key(prefix)
|
|
138
|
+
all_keys = [k for k in object_storage.list(prefix) if k != state_key_name]
|
|
139
|
+
report.scanned_blobs = len(all_keys)
|
|
140
|
+
|
|
141
|
+
referenced = _referenced_blob_keys(adapter, models)
|
|
142
|
+
report.referenced_blobs = len(referenced)
|
|
143
|
+
|
|
144
|
+
state = _load_state(object_storage, prefix)
|
|
145
|
+
next_state: dict = {}
|
|
146
|
+
|
|
147
|
+
for key in all_keys:
|
|
148
|
+
if key in referenced:
|
|
149
|
+
continue # live -- never a candidate, never written into next_state
|
|
150
|
+
|
|
151
|
+
first_seen = state.get(key)
|
|
152
|
+
if first_seen is None:
|
|
153
|
+
# First sweep to see this one orphaned: start its clock, don't delete yet.
|
|
154
|
+
next_state[key] = now
|
|
155
|
+
report.new_candidates.append(key)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if now - first_seen < grace_seconds:
|
|
159
|
+
next_state[key] = first_seen
|
|
160
|
+
report.still_pending.append(key)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
# Orphaned on this sweep AND the previous one, past the grace window.
|
|
164
|
+
if dry_run:
|
|
165
|
+
report.deleted.append(key) # what WOULD be deleted
|
|
166
|
+
next_state[key] = first_seen
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
object_storage.delete(key)
|
|
171
|
+
report.deleted.append(key)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
report.errors.append((key, str(e)))
|
|
174
|
+
next_state[key] = first_seen # retry on the next sweep
|
|
175
|
+
|
|
176
|
+
if not dry_run:
|
|
177
|
+
_save_state(object_storage, prefix, next_state)
|
|
178
|
+
|
|
179
|
+
return report
|
polydb/query.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# src/polydb/query.py
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
|
+
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
|
-
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
6
6
|
from enum import Enum
|
|
7
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
8
|
+
|
|
7
9
|
from .errors import ValidationError
|
|
8
10
|
from .utils import validate_column_name
|
|
9
11
|
|
polydb/retry.py
CHANGED
polydb/schema.py
CHANGED
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
"""
|
|
3
3
|
Schema management and migrations
|
|
4
4
|
"""
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from decimal import Decimal
|
|
8
8
|
from enum import Enum
|
|
9
|
-
import
|
|
10
|
-
from datetime import datetime
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
11
10
|
|
|
12
11
|
from .errors import ValidationError
|
|
13
12
|
from .utils import validate_column_name, validate_table_name
|
|
@@ -66,22 +65,22 @@ class Index:
|
|
|
66
65
|
|
|
67
66
|
class SchemaBuilder:
|
|
68
67
|
"""Build SQL schema definitions"""
|
|
69
|
-
|
|
68
|
+
|
|
70
69
|
def __init__(self):
|
|
71
70
|
self.columns: List[Column] = []
|
|
72
71
|
self.indexes: List[Index] = []
|
|
73
72
|
self.primary_keys: List[str] = []
|
|
74
|
-
|
|
75
|
-
def add_column(self, column: Column) ->
|
|
73
|
+
|
|
74
|
+
def add_column(self, column: Column) -> "SchemaBuilder":
|
|
76
75
|
self.columns.append(column)
|
|
77
76
|
if column.primary_key:
|
|
78
77
|
self.primary_keys.append(column.name)
|
|
79
78
|
return self
|
|
80
|
-
|
|
81
|
-
def add_index(self, index: Index) ->
|
|
79
|
+
|
|
80
|
+
def add_index(self, index: Index) -> "SchemaBuilder":
|
|
82
81
|
self.indexes.append(index)
|
|
83
82
|
return self
|
|
84
|
-
|
|
83
|
+
|
|
85
84
|
def to_create_table(self, table_name: str) -> str:
|
|
86
85
|
"""Generate CREATE TABLE statement"""
|
|
87
86
|
validate_table_name(table_name)
|
|
@@ -89,42 +88,42 @@ class SchemaBuilder:
|
|
|
89
88
|
|
|
90
89
|
for col in self.columns:
|
|
91
90
|
parts = [validate_column_name(col.name)]
|
|
92
|
-
|
|
91
|
+
|
|
93
92
|
# Type
|
|
94
93
|
if col.type == ColumnType.VARCHAR and col.max_length:
|
|
95
94
|
parts.append(f"VARCHAR({col.max_length})")
|
|
96
95
|
else:
|
|
97
96
|
parts.append(col.type.value)
|
|
98
|
-
|
|
97
|
+
|
|
99
98
|
# Nullable
|
|
100
99
|
if not col.nullable:
|
|
101
100
|
parts.append("NOT NULL")
|
|
102
|
-
|
|
101
|
+
|
|
103
102
|
# Default
|
|
104
103
|
if col.default is not None:
|
|
105
104
|
parts.append(_render_default(col.default))
|
|
106
|
-
|
|
105
|
+
|
|
107
106
|
# Unique
|
|
108
107
|
if col.unique:
|
|
109
108
|
parts.append("UNIQUE")
|
|
110
|
-
|
|
109
|
+
|
|
111
110
|
col_defs.append(" ".join(parts))
|
|
112
|
-
|
|
111
|
+
|
|
113
112
|
# Primary key
|
|
114
113
|
if self.primary_keys:
|
|
115
114
|
pk_cols = [validate_column_name(c) for c in self.primary_keys]
|
|
116
115
|
col_defs.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
|
|
117
|
-
|
|
116
|
+
|
|
118
117
|
sql = f"CREATE TABLE IF NOT EXISTS {table_name} (\n"
|
|
119
118
|
sql += ",\n".join(f" {col}" for col in col_defs)
|
|
120
119
|
sql += "\n);"
|
|
121
|
-
|
|
120
|
+
|
|
122
121
|
return sql
|
|
123
|
-
|
|
122
|
+
|
|
124
123
|
def to_create_indexes(self, table_name: str) -> List[str]:
|
|
125
124
|
"""Generate CREATE INDEX statements"""
|
|
126
125
|
statements = []
|
|
127
|
-
|
|
126
|
+
|
|
128
127
|
validate_table_name(table_name)
|
|
129
128
|
|
|
130
129
|
for idx in self.indexes:
|
|
@@ -135,17 +134,17 @@ class SchemaBuilder:
|
|
|
135
134
|
f"ON {table_name}({cols});"
|
|
136
135
|
)
|
|
137
136
|
statements.append(sql)
|
|
138
|
-
|
|
137
|
+
|
|
139
138
|
return statements
|
|
140
139
|
|
|
141
140
|
|
|
142
141
|
class MigrationManager:
|
|
143
142
|
"""Database migration management"""
|
|
144
|
-
|
|
143
|
+
|
|
145
144
|
def __init__(self, sql_adapter):
|
|
146
145
|
self.sql = sql_adapter
|
|
147
146
|
self._ensure_migrations_table()
|
|
148
|
-
|
|
147
|
+
|
|
149
148
|
def _ensure_migrations_table(self):
|
|
150
149
|
"""Create migrations tracking table"""
|
|
151
150
|
schema = """
|
|
@@ -159,74 +158,60 @@ class MigrationManager:
|
|
|
159
158
|
);
|
|
160
159
|
"""
|
|
161
160
|
self.sql.execute(schema)
|
|
162
|
-
|
|
161
|
+
|
|
163
162
|
def apply_migration(
|
|
164
|
-
self,
|
|
165
|
-
version: str,
|
|
166
|
-
name: str,
|
|
167
|
-
up_sql: str,
|
|
168
|
-
down_sql: Optional[str] = None
|
|
163
|
+
self, version: str, name: str, up_sql: str, down_sql: Optional[str] = None
|
|
169
164
|
) -> bool:
|
|
170
165
|
"""Apply a migration"""
|
|
171
166
|
import hashlib
|
|
172
|
-
|
|
167
|
+
|
|
173
168
|
# Check if already applied
|
|
174
169
|
existing = self.sql.execute(
|
|
175
|
-
"SELECT version FROM polydb_migrations WHERE version = %s",
|
|
176
|
-
[version],
|
|
177
|
-
fetch_one=True
|
|
170
|
+
"SELECT version FROM polydb_migrations WHERE version = %s", [version], fetch_one=True
|
|
178
171
|
)
|
|
179
|
-
|
|
172
|
+
|
|
180
173
|
if existing:
|
|
181
174
|
return False
|
|
182
|
-
|
|
175
|
+
|
|
183
176
|
# Calculate checksum
|
|
184
177
|
checksum = hashlib.sha256(up_sql.encode()).hexdigest()
|
|
185
|
-
|
|
178
|
+
|
|
186
179
|
try:
|
|
187
180
|
# Execute migration
|
|
188
181
|
self.sql.execute(up_sql)
|
|
189
|
-
|
|
182
|
+
|
|
190
183
|
# Record migration
|
|
191
|
-
self.sql.insert(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
})
|
|
197
|
-
|
|
184
|
+
self.sql.insert(
|
|
185
|
+
"polydb_migrations",
|
|
186
|
+
{"version": version, "name": name, "rollback_sql": down_sql, "checksum": checksum},
|
|
187
|
+
)
|
|
188
|
+
|
|
198
189
|
return True
|
|
199
190
|
except Exception as e:
|
|
200
191
|
raise Exception(f"Migration {version} failed: {str(e)}")
|
|
201
|
-
|
|
192
|
+
|
|
202
193
|
def rollback_migration(self, version: str) -> bool:
|
|
203
194
|
"""Rollback a migration"""
|
|
204
195
|
migration = self.sql.execute(
|
|
205
196
|
"SELECT rollback_sql FROM polydb_migrations WHERE version = %s",
|
|
206
197
|
[version],
|
|
207
|
-
fetch_one=True
|
|
198
|
+
fetch_one=True,
|
|
208
199
|
)
|
|
209
|
-
|
|
210
|
-
if not migration or not migration.get(
|
|
200
|
+
|
|
201
|
+
if not migration or not migration.get("rollback_sql"):
|
|
211
202
|
raise Exception(f"No rollback available for {version}")
|
|
212
|
-
|
|
203
|
+
|
|
213
204
|
try:
|
|
214
205
|
# Execute rollback
|
|
215
|
-
self.sql.execute(migration[
|
|
216
|
-
|
|
206
|
+
self.sql.execute(migration["rollback_sql"])
|
|
207
|
+
|
|
217
208
|
# Remove from migrations
|
|
218
|
-
self.sql.execute(
|
|
219
|
-
|
|
220
|
-
[version]
|
|
221
|
-
)
|
|
222
|
-
|
|
209
|
+
self.sql.execute("DELETE FROM polydb_migrations WHERE version = %s", [version])
|
|
210
|
+
|
|
223
211
|
return True
|
|
224
212
|
except Exception as e:
|
|
225
213
|
raise Exception(f"Rollback {version} failed: {str(e)}")
|
|
226
|
-
|
|
214
|
+
|
|
227
215
|
def get_applied_migrations(self) -> List[Dict[str, Any]]:
|
|
228
216
|
"""Get list of applied migrations"""
|
|
229
|
-
return self.sql.execute(
|
|
230
|
-
"SELECT * FROM polydb_migrations ORDER BY applied_at",
|
|
231
|
-
fetch=True
|
|
232
|
-
)
|
|
217
|
+
return self.sql.execute("SELECT * FROM polydb_migrations ORDER BY applied_at", fetch=True)
|