altcodepro-polydb-python 2.3.16__py3-none-any.whl → 2.3.18__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.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/METADATA +1 -1
- {altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/RECORD +8 -8
- polydb/databaseFactory.py +56 -96
- polydb/decorators.py +1031 -12
- polydb/retry.py +23 -0
- {altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/WHEEL +0 -0
- {altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/licenses/LICENSE +0 -0
- {altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/top_level.txt +0 -0
{altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: altcodepro-polydb-python
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.18
|
|
4
4
|
Summary: Production-ready multi-cloud database abstraction layer with connection pooling, retry logic, and thread safety
|
|
5
5
|
Author: AltCodePro
|
|
6
6
|
Project-URL: Homepage, https://github.com/altcodepro/polydb-python
|
{altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/RECORD
RENAMED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
altcodepro_polydb_python-2.3.
|
|
1
|
+
altcodepro_polydb_python-2.3.18.dist-info/licenses/LICENSE,sha256=9X8GLocsBwy-5aR5JGOt2SAMDDPs9Qv-YnqmHBHOXrw,1067
|
|
2
2
|
polydb/PolyDB.py,sha256=MG7-nV59zDvrUQwXNEgV4eetHCeW9TYY9DQqtxu_r7k,23543
|
|
3
3
|
polydb/__init__.py,sha256=UhUzfSvmMgKbV2tSME1ooIyfshIBi7_WyU4xl1tWWiA,1454
|
|
4
4
|
polydb/advanced_query.py,sha256=cxMB-EB-qT3bWXJlhmjnMCUtrzogORWyoEfS50Dy7go,4280
|
|
5
5
|
polydb/batch.py,sha256=_DjWZa1ZXYSk6MLKqFe0eT7SYVRZtYNqZb9bI8Y2sao,4566
|
|
6
6
|
polydb/cache.py,sha256=JBXF1XEK-fY80ar8SDE893Z1Z116YtXAEG0PaJ0Nkcw,7658
|
|
7
7
|
polydb/cloudDatabaseFactory.py,sha256=Gp6L__YtgrkGahD8B7ItzXMHCoj2ZUGDjXLS9w0TujY,17780
|
|
8
|
-
polydb/databaseFactory.py,sha256=
|
|
9
|
-
polydb/decorators.py,sha256=
|
|
8
|
+
polydb/databaseFactory.py,sha256=u20sy8bA09A0jTO2vLxGznssdcaxChQf21FBpooL2oA,40265
|
|
9
|
+
polydb/decorators.py,sha256=L_WP2uXP_k8Ac49SUm-mthbM4jWI-XYfHXEyKzumOww,43062
|
|
10
10
|
polydb/errors.py,sha256=rcFeBH0cenjJ86v0cmDc2Yjj4R019pLCBcTeSC4qps4,1428
|
|
11
11
|
polydb/json_safe.py,sha256=R5PrqAGirqjYKPyy-8KH-lSXjLH0FPr2TSGozy4eheU,149
|
|
12
12
|
polydb/models.py,sha256=9uu_BaJ95194n-vnd0Rx9KLc6aPS-mxn10P4W5grUcI,8155
|
|
@@ -15,7 +15,7 @@ polydb/multitenancy.py,sha256=9kyY98RpKg8xDy9ejB_MyV_YzF7eZd4uxashw5S8vlg,6408
|
|
|
15
15
|
polydb/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
polydb/query.py,sha256=3oWgRXIYLItmd_R-7u78hCdUgUaoiFmjVg38vi2-rVA,6600
|
|
17
17
|
polydb/registry.py,sha256=RD_elvFXcmhTdCyZDm2f3ej0elxqhArnSJ2aO9k5VCU,2352
|
|
18
|
-
polydb/retry.py,sha256=
|
|
18
|
+
polydb/retry.py,sha256=AWq-Ia5aLL2MnOmMB0dfNT5bSJLQySLveLfwZUTMQfs,3403
|
|
19
19
|
polydb/schema.py,sha256=VrOayX6V6AD2Qh3-lm4ZVPTpI24e4V52IYheZf2rNQ4,5812
|
|
20
20
|
polydb/security.py,sha256=9ju-hc6Y1sxobCoV_mZ3ZWroUD73LodyTLVMhY_HeKU,16360
|
|
21
21
|
polydb/types.py,sha256=XB_85Un8_aWt4dSfpjIGotHbK3KBY2WurQGXr9EOxWY,2992
|
|
@@ -55,7 +55,7 @@ polydb/base/ObjectStorageAdapter.py,sha256=VeJ3qXET6H0xd3lJpE8-WSsKs8EyK9S0-9VNR
|
|
|
55
55
|
polydb/base/QueueAdapter.py,sha256=jFgyG-SUK4nhRNxm2NbzUbwnA9b_5iAC-ikLSUpXRwk,799
|
|
56
56
|
polydb/base/SharedFilesAdapter.py,sha256=kXbJmtn_cwEyAZ-1AvFrmesCLSwu43ycTV3S4BmwrO4,853
|
|
57
57
|
polydb/base/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
58
|
-
altcodepro_polydb_python-2.3.
|
|
59
|
-
altcodepro_polydb_python-2.3.
|
|
60
|
-
altcodepro_polydb_python-2.3.
|
|
61
|
-
altcodepro_polydb_python-2.3.
|
|
58
|
+
altcodepro_polydb_python-2.3.18.dist-info/METADATA,sha256=Jc_9hMUK2yeGjUclvNlQ5cj-8dADdz1P6tDFvl6spz8,12359
|
|
59
|
+
altcodepro_polydb_python-2.3.18.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
60
|
+
altcodepro_polydb_python-2.3.18.dist-info/top_level.txt,sha256=WgLFWJoYjUhwvyPxJFl6jYLrVFuBJDX3OABf4ocwk_E,7
|
|
61
|
+
altcodepro_polydb_python-2.3.18.dist-info/RECORD,,
|
polydb/databaseFactory.py
CHANGED
|
@@ -34,6 +34,7 @@ from .audit.manager import AuditManager
|
|
|
34
34
|
from .audit.context import AuditContext
|
|
35
35
|
from .query import Operator, QueryBuilder
|
|
36
36
|
from .cloudDatabaseFactory import CloudDatabaseFactory
|
|
37
|
+
import re as _re
|
|
37
38
|
|
|
38
39
|
logger = logging.getLogger(__name__)
|
|
39
40
|
|
|
@@ -43,6 +44,33 @@ _DEFAULT_RETRY = retry(
|
|
|
43
44
|
reraise=True,
|
|
44
45
|
)
|
|
45
46
|
|
|
47
|
+
_UNIQUE_VIOLATION_MARKERS = (
|
|
48
|
+
"23505", # Postgres SQLSTATE
|
|
49
|
+
"duplicate key value violates", # Postgres message
|
|
50
|
+
"unique constraint", # Postgres, generic
|
|
51
|
+
"UniqueViolation", # psycopg / SQLAlchemy class name
|
|
52
|
+
"Duplicate entry", # MySQL
|
|
53
|
+
"UNIQUE constraint failed", # SQLite
|
|
54
|
+
)
|
|
55
|
+
_UNIQUE_KEY_RE = _re.compile(r"Key \(([^)]+)\)=")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_unique_violation(exc: BaseException) -> bool:
|
|
59
|
+
s = str(exc)
|
|
60
|
+
return any(m in s for m in _UNIQUE_VIOLATION_MARKERS)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_unique_violation_columns(exc: BaseException) -> list[str]:
|
|
64
|
+
"""
|
|
65
|
+
Pull the conflicting column names out of a Postgres unique-violation error.
|
|
66
|
+
Postgres formats them as: Key (col1, col2)=(val1, val2) already exists.
|
|
67
|
+
Returns [] if the message doesn't carry that detail.
|
|
68
|
+
"""
|
|
69
|
+
m = _UNIQUE_KEY_RE.search(str(exc))
|
|
70
|
+
if not m:
|
|
71
|
+
return []
|
|
72
|
+
return [c.strip() for c in m.group(1).split(",") if c.strip()]
|
|
73
|
+
|
|
46
74
|
|
|
47
75
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
48
76
|
# ENGINE CONFIG
|
|
@@ -322,46 +350,8 @@ class DatabaseFactory:
|
|
|
322
350
|
result.setdefault("deleted_at", None)
|
|
323
351
|
return result
|
|
324
352
|
|
|
325
|
-
def _audit_safe(
|
|
326
|
-
self,
|
|
327
|
-
*,
|
|
328
|
-
action: str,
|
|
329
|
-
model: Union[type, str],
|
|
330
|
-
entity_id: Optional[Any],
|
|
331
|
-
meta: ModelMeta,
|
|
332
|
-
success: bool,
|
|
333
|
-
before: Optional[JsonDict],
|
|
334
|
-
after: Optional[JsonDict],
|
|
335
|
-
error: Optional[str],
|
|
336
|
-
engine_name: Optional[str] = None,
|
|
337
|
-
) -> None:
|
|
338
|
-
if not self._audit:
|
|
339
|
-
return
|
|
340
|
-
try:
|
|
341
|
-
changed = None
|
|
342
|
-
if before and after:
|
|
343
|
-
changed = [
|
|
344
|
-
k for k in set(before) | set(after) if before.get(k) != after.get(k)
|
|
345
|
-
] or None
|
|
346
|
-
self._audit.record(
|
|
347
|
-
action=action,
|
|
348
|
-
model=_model_name(model),
|
|
349
|
-
entity_id=str(entity_id) if entity_id else None,
|
|
350
|
-
storage_type=meta.storage,
|
|
351
|
-
provider=engine_name or self._provider_name,
|
|
352
|
-
success=success,
|
|
353
|
-
before=before,
|
|
354
|
-
after=after,
|
|
355
|
-
changed_fields=changed,
|
|
356
|
-
error=error,
|
|
357
|
-
)
|
|
358
|
-
except Exception as exc:
|
|
359
|
-
logger.error("Audit recording failed: %s", exc)
|
|
360
|
-
|
|
361
353
|
def _run(self, fn: Callable[[], Any]) -> Any:
|
|
362
|
-
|
|
363
|
-
return fn()
|
|
364
|
-
return _DEFAULT_RETRY(fn)()
|
|
354
|
+
return fn()
|
|
365
355
|
|
|
366
356
|
def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
|
|
367
357
|
if override and override.force_sql:
|
|
@@ -398,7 +388,29 @@ class DatabaseFactory:
|
|
|
398
388
|
def _op() -> JsonDict:
|
|
399
389
|
nonlocal after_plain, success, entity_id
|
|
400
390
|
if self._is_sql(meta, engine_override):
|
|
401
|
-
|
|
391
|
+
try:
|
|
392
|
+
result = adapters.sql.insert(meta.table, data)
|
|
393
|
+
except Exception as exc:
|
|
394
|
+
if not _is_unique_violation(exc):
|
|
395
|
+
raise
|
|
396
|
+
# Half-ran scenario / re-activation / replay. The record already
|
|
397
|
+
# exists with these unique-key columns. Preserve idempotent
|
|
398
|
+
# "create or update" semantics by routing to UPDATE keyed on the
|
|
399
|
+
# exact columns that conflicted (parsed from the Postgres error).
|
|
400
|
+
conflict_cols = _parse_unique_violation_columns(exc)
|
|
401
|
+
if not conflict_cols or not all(c in data for c in conflict_cols):
|
|
402
|
+
# Can't determine the conflict — re-raise so the caller sees it.
|
|
403
|
+
raise
|
|
404
|
+
where = {c: data[c] for c in conflict_cols}
|
|
405
|
+
logger.warning(
|
|
406
|
+
"insert %s hit unique violation on %s — falling through to update",
|
|
407
|
+
meta.table,
|
|
408
|
+
conflict_cols,
|
|
409
|
+
)
|
|
410
|
+
# Drop the conflict columns from the UPDATE SET clause — they're
|
|
411
|
+
# already the matching key.
|
|
412
|
+
update_data = {k: v for k, v in data.items() if k not in conflict_cols}
|
|
413
|
+
result = adapters.sql.update(meta.table, where, update_data)
|
|
402
414
|
else:
|
|
403
415
|
result = adapters.nosql.put(
|
|
404
416
|
(
|
|
@@ -427,21 +439,8 @@ class DatabaseFactory:
|
|
|
427
439
|
m.rows_affected = 1
|
|
428
440
|
return result
|
|
429
441
|
return self._run(_op)
|
|
430
|
-
except Exception
|
|
431
|
-
error = str(exc)
|
|
442
|
+
except Exception:
|
|
432
443
|
raise
|
|
433
|
-
finally:
|
|
434
|
-
self._audit_safe(
|
|
435
|
-
action="create",
|
|
436
|
-
model=model,
|
|
437
|
-
entity_id=entity_id,
|
|
438
|
-
meta=meta,
|
|
439
|
-
success=success,
|
|
440
|
-
before=None,
|
|
441
|
-
after=after_plain,
|
|
442
|
-
error=error,
|
|
443
|
-
engine_name=adapters.engine_name,
|
|
444
|
-
)
|
|
445
444
|
|
|
446
445
|
# ──────────────────────────────────────────────────────────────────────
|
|
447
446
|
# READ
|
|
@@ -605,21 +604,8 @@ class DatabaseFactory:
|
|
|
605
604
|
m.rows_affected = 1
|
|
606
605
|
return result
|
|
607
606
|
return self._run(_op)
|
|
608
|
-
except Exception
|
|
609
|
-
error = str(exc)
|
|
607
|
+
except Exception:
|
|
610
608
|
raise
|
|
611
|
-
finally:
|
|
612
|
-
self._audit_safe(
|
|
613
|
-
action="update",
|
|
614
|
-
model=model,
|
|
615
|
-
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
616
|
-
meta=meta,
|
|
617
|
-
success=success,
|
|
618
|
-
before=before,
|
|
619
|
-
after=after_plain,
|
|
620
|
-
error=error,
|
|
621
|
-
engine_name=adapters.engine_name,
|
|
622
|
-
)
|
|
623
609
|
|
|
624
610
|
# ──────────────────────────────────────────────────────────────────────
|
|
625
611
|
# UPSERT
|
|
@@ -675,21 +661,8 @@ class DatabaseFactory:
|
|
|
675
661
|
m.rows_affected = 1
|
|
676
662
|
return result
|
|
677
663
|
return self._run(_op)
|
|
678
|
-
except Exception
|
|
679
|
-
error = str(exc)
|
|
664
|
+
except Exception:
|
|
680
665
|
raise
|
|
681
|
-
finally:
|
|
682
|
-
self._audit_safe(
|
|
683
|
-
action="upsert",
|
|
684
|
-
model=model,
|
|
685
|
-
entity_id=None,
|
|
686
|
-
meta=meta,
|
|
687
|
-
success=success,
|
|
688
|
-
before=None,
|
|
689
|
-
after=after_plain,
|
|
690
|
-
error=error,
|
|
691
|
-
engine_name=adapters.engine_name,
|
|
692
|
-
)
|
|
693
666
|
|
|
694
667
|
# ──────────────────────────────────────────────────────────────────────
|
|
695
668
|
# DELETE
|
|
@@ -755,21 +728,8 @@ class DatabaseFactory:
|
|
|
755
728
|
m.rows_affected = 1
|
|
756
729
|
return result
|
|
757
730
|
return self._run(_op)
|
|
758
|
-
except Exception
|
|
759
|
-
error = str(exc)
|
|
731
|
+
except Exception:
|
|
760
732
|
raise
|
|
761
|
-
finally:
|
|
762
|
-
self._audit_safe(
|
|
763
|
-
action="delete",
|
|
764
|
-
model=model,
|
|
765
|
-
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
766
|
-
meta=meta,
|
|
767
|
-
success=success,
|
|
768
|
-
before=before,
|
|
769
|
-
after=None,
|
|
770
|
-
error=error,
|
|
771
|
-
engine_name=adapters.engine_name,
|
|
772
|
-
)
|
|
773
733
|
|
|
774
734
|
# ──────────────────────────────────────────────────────────────────────
|
|
775
735
|
# QUERY (LINQ-style)
|
polydb/decorators.py
CHANGED
|
@@ -1,21 +1,1040 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
DatabaseFactory — Pure Storage Layer
|
|
3
|
+
=====================================
|
|
4
|
+
|
|
5
|
+
Multi-engine CRUD, blob, queue, cache, file operations.
|
|
6
|
+
|
|
7
|
+
NO business logic. NO tenant enforcement. NO model registry validation.
|
|
8
|
+
NO RLS. Those belong in UDL.
|
|
9
|
+
|
|
10
|
+
PolyDB is the dumb storage layer. UDL is the smart layer.
|
|
11
|
+
"""
|
|
12
|
+
|
|
2
13
|
from __future__ import annotations
|
|
3
14
|
|
|
4
|
-
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
20
|
+
|
|
21
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
22
|
+
|
|
23
|
+
from .adapters.PostgreSQLAdapter import PostgreSQLAdapter
|
|
24
|
+
|
|
25
|
+
from .base.NoSQLKVAdapter import NoSQLKVAdapter
|
|
26
|
+
|
|
27
|
+
from .batch import BatchOperations
|
|
28
|
+
from .cache import CacheWarmer, RedisCacheEngine
|
|
29
|
+
from .monitoring import HealthCheck, MetricsCollector, PerformanceMonitor
|
|
30
|
+
from .security import DataMasking, FieldEncryption
|
|
31
|
+
from .errors import AdapterConfigurationError
|
|
32
|
+
from .types import JsonDict, Lookup, ModelMeta
|
|
33
|
+
from .audit.manager import AuditManager
|
|
34
|
+
from .audit.context import AuditContext
|
|
35
|
+
from .query import Operator, QueryBuilder
|
|
36
|
+
from .cloudDatabaseFactory import CloudDatabaseFactory
|
|
37
|
+
import re as _re
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
_DEFAULT_RETRY = retry(
|
|
41
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=6),
|
|
42
|
+
stop=stop_after_attempt(3),
|
|
43
|
+
reraise=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
_UNIQUE_VIOLATION_MARKERS = (
|
|
47
|
+
"23505", # Postgres SQLSTATE
|
|
48
|
+
"duplicate key value violates", # Postgres message
|
|
49
|
+
"unique constraint", # Postgres, generic
|
|
50
|
+
"UniqueViolation", # psycopg / SQLAlchemy class name
|
|
51
|
+
"Duplicate entry", # MySQL
|
|
52
|
+
"UNIQUE constraint failed", # SQLite
|
|
53
|
+
)
|
|
54
|
+
_UNIQUE_KEY_RE = _re.compile(r"Key \(([^)]+)\)=")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_unique_violation(exc: BaseException) -> bool:
|
|
58
|
+
s = str(exc)
|
|
59
|
+
return any(m in s for m in _UNIQUE_VIOLATION_MARKERS)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_unique_violation_columns(exc: BaseException) -> list[str]:
|
|
63
|
+
"""
|
|
64
|
+
Pull the conflicting column names out of a Postgres unique-violation error.
|
|
65
|
+
Postgres formats them as: Key (col1, col2)=(val1, val2) already exists.
|
|
66
|
+
Returns [] if the message doesn't carry that detail.
|
|
67
|
+
"""
|
|
68
|
+
m = _UNIQUE_KEY_RE.search(str(exc))
|
|
69
|
+
if not m:
|
|
70
|
+
return []
|
|
71
|
+
return [c.strip() for c in m.group(1).split(",") if c.strip()]
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
# ENGINE CONFIG
|
|
74
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class EngineConfig:
|
|
79
|
+
"""Single SQL or NoSQL engine that DatabaseFactory can route to."""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
cloud_factory: CloudDatabaseFactory
|
|
83
|
+
sql_models: Optional[Set[str]] = None
|
|
84
|
+
nosql_models: Optional[Set[str]] = None
|
|
85
|
+
is_default_sql: bool = False
|
|
86
|
+
is_default_nosql: bool = False
|
|
87
|
+
|
|
88
|
+
_sql: Any = field(default=None, init=False, repr=False)
|
|
89
|
+
_nosql: Any = field(default=None, init=False, repr=False)
|
|
90
|
+
|
|
91
|
+
def sql(self) -> Any:
|
|
92
|
+
if self._sql is None:
|
|
93
|
+
self._sql = self.cloud_factory.get_sql()
|
|
94
|
+
return self._sql
|
|
95
|
+
|
|
96
|
+
def nosql(self) -> Any:
|
|
97
|
+
if self._nosql is None:
|
|
98
|
+
self._nosql = self.cloud_factory.get_nosql_kv()
|
|
99
|
+
return self._nosql
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class EngineOverride:
|
|
104
|
+
"""Per-call override to bypass routing and target a specific engine."""
|
|
105
|
+
|
|
106
|
+
engine_name: str
|
|
107
|
+
force_sql: bool = False
|
|
108
|
+
force_nosql: bool = False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class _ResolvedAdapters:
|
|
113
|
+
sql: PostgreSQLAdapter
|
|
114
|
+
nosql: NoSQLKVAdapter
|
|
115
|
+
engine_name: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
119
|
+
# MODEL META RESOLUTION (lightweight — no registry enforcement)
|
|
120
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _extract_meta(model: Union[type, str]) -> ModelMeta:
|
|
124
|
+
"""
|
|
125
|
+
Extract storage metadata from model class.
|
|
126
|
+
|
|
127
|
+
If model is a string, return a default NoSQL meta (UDL resolves the
|
|
128
|
+
class before calling PolyDB, so string fallback is safe).
|
|
129
|
+
"""
|
|
130
|
+
if isinstance(model, type):
|
|
131
|
+
raw = getattr(model, "__polydb__", None)
|
|
132
|
+
if raw:
|
|
133
|
+
return ModelMeta(
|
|
134
|
+
storage=raw.get("storage", "nosql"),
|
|
135
|
+
table=raw.get("table"),
|
|
136
|
+
collection=raw.get("collection"),
|
|
137
|
+
pk_field=raw.get("pk_field", raw.get("partition_key")),
|
|
138
|
+
rk_field=raw.get("rk_field", raw.get("sort_key")),
|
|
139
|
+
provider=raw.get("provider"),
|
|
140
|
+
cache=raw.get("cache", False),
|
|
141
|
+
cache_ttl=raw.get("cache_ttl"),
|
|
142
|
+
)
|
|
143
|
+
# Default for dynamic/string models
|
|
144
|
+
return ModelMeta(storage="nosql", table=None, collection=None)
|
|
145
|
+
|
|
5
146
|
|
|
6
|
-
|
|
147
|
+
def _model_name(model: Union[type, str]) -> str:
|
|
148
|
+
return model.__name__ if isinstance(model, type) else str(model)
|
|
7
149
|
|
|
8
|
-
T = TypeVar("T", bound=type)
|
|
9
150
|
|
|
151
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
152
|
+
# DATABASE FACTORY
|
|
153
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
10
154
|
|
|
11
|
-
|
|
155
|
+
|
|
156
|
+
class DatabaseFactory:
|
|
12
157
|
"""
|
|
13
|
-
|
|
158
|
+
Pure storage layer. Multi-engine CRUD with:
|
|
159
|
+
- Multi-engine routing (sql_models / nosql_models per engine)
|
|
160
|
+
- Per-call engine override
|
|
161
|
+
- Cache, audit, encryption, monitoring
|
|
162
|
+
- Blob, queue, file, cache storage
|
|
14
163
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class UserEntity:
|
|
18
|
-
__polydb__ = {"storage": "nosql"}
|
|
164
|
+
NO tenant enforcement. NO model registry validation. NO RLS.
|
|
165
|
+
UDL handles all of that.
|
|
19
166
|
"""
|
|
20
|
-
|
|
21
|
-
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
# Single-engine (backwards-compatible)
|
|
172
|
+
provider: Optional[Any] = None,
|
|
173
|
+
cloud_factory: Optional[CloudDatabaseFactory] = None,
|
|
174
|
+
# Multi-engine
|
|
175
|
+
engines: Optional[List[EngineConfig]] = None,
|
|
176
|
+
# Feature flags
|
|
177
|
+
redis_cache_url: Optional[str] = None,
|
|
178
|
+
enable_retries: bool = True,
|
|
179
|
+
enable_audit: bool = True,
|
|
180
|
+
enable_audit_reads: bool = False,
|
|
181
|
+
enable_cache: bool = True,
|
|
182
|
+
soft_delete: bool = False,
|
|
183
|
+
use_redis_cache: bool = False,
|
|
184
|
+
enable_monitoring: bool = False,
|
|
185
|
+
enable_encryption: bool = False,
|
|
186
|
+
) -> None:
|
|
187
|
+
self._enable_retries = enable_retries
|
|
188
|
+
self._enable_audit = enable_audit
|
|
189
|
+
self._enable_audit_reads = enable_audit_reads
|
|
190
|
+
self._enable_cache = enable_cache
|
|
191
|
+
self._soft_delete = soft_delete
|
|
192
|
+
|
|
193
|
+
# Monitoring
|
|
194
|
+
self.metrics = MetricsCollector() if enable_monitoring else None
|
|
195
|
+
self.health = HealthCheck(self) if enable_monitoring else None
|
|
196
|
+
|
|
197
|
+
# Redis cache
|
|
198
|
+
self._cache: Optional[RedisCacheEngine] = None
|
|
199
|
+
self.cache_warmer: Optional[CacheWarmer] = None
|
|
200
|
+
if enable_cache and use_redis_cache:
|
|
201
|
+
redis_url = redis_cache_url or os.getenv("REDIS_CACHE_URL") or os.getenv("REDIS_URL")
|
|
202
|
+
if redis_url:
|
|
203
|
+
self._cache = RedisCacheEngine(redis_url=redis_url)
|
|
204
|
+
self.cache_warmer = CacheWarmer(self, self._cache)
|
|
205
|
+
else:
|
|
206
|
+
logger.warning("use_redis_cache=True but REDIS_CACHE_URL not set")
|
|
207
|
+
|
|
208
|
+
# Encryption
|
|
209
|
+
self.encryption = FieldEncryption() if enable_encryption else None
|
|
210
|
+
self.masking = DataMasking()
|
|
211
|
+
|
|
212
|
+
self.batch = BatchOperations(self)
|
|
213
|
+
self._audit = AuditManager() if enable_audit else None
|
|
214
|
+
|
|
215
|
+
# Engine registry
|
|
216
|
+
self._engines: List[EngineConfig] = []
|
|
217
|
+
|
|
218
|
+
if engines:
|
|
219
|
+
self._engines = engines
|
|
220
|
+
default_sql = [e for e in engines if e.is_default_sql]
|
|
221
|
+
default_nosql = [e for e in engines if e.is_default_nosql]
|
|
222
|
+
if len(default_sql) > 1:
|
|
223
|
+
raise AdapterConfigurationError("More than one engine marked is_default_sql=True")
|
|
224
|
+
if len(default_nosql) > 1:
|
|
225
|
+
raise AdapterConfigurationError("More than one engine marked is_default_nosql=True")
|
|
226
|
+
else:
|
|
227
|
+
_cf = cloud_factory or CloudDatabaseFactory(provider=provider)
|
|
228
|
+
self._engines = [
|
|
229
|
+
EngineConfig(
|
|
230
|
+
name="primary", cloud_factory=_cf, is_default_sql=True, is_default_nosql=True
|
|
231
|
+
)
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
self._engine_by_name: Dict[str, EngineConfig] = {e.name: e for e in self._engines}
|
|
235
|
+
self._provider_name = self._engines[0].cloud_factory.provider.value
|
|
236
|
+
|
|
237
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
238
|
+
# ENGINE ROUTING
|
|
239
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def _resolve_adapters(
|
|
242
|
+
self, model_name: str, storage: str, override: Optional[EngineOverride] = None
|
|
243
|
+
) -> _ResolvedAdapters:
|
|
244
|
+
# 1. Per-call override
|
|
245
|
+
if override:
|
|
246
|
+
engine = self._engine_by_name.get(override.engine_name)
|
|
247
|
+
if engine is None:
|
|
248
|
+
raise AdapterConfigurationError(
|
|
249
|
+
f"Unknown engine '{override.engine_name}'. Available: {list(self._engine_by_name)}"
|
|
250
|
+
)
|
|
251
|
+
return _ResolvedAdapters(
|
|
252
|
+
sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# 2. Explicit allow-list
|
|
256
|
+
for engine in self._engines:
|
|
257
|
+
if storage == "sql" and engine.sql_models and model_name in engine.sql_models:
|
|
258
|
+
return _ResolvedAdapters(
|
|
259
|
+
sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
|
|
260
|
+
)
|
|
261
|
+
if storage == "nosql" and engine.nosql_models and model_name in engine.nosql_models:
|
|
262
|
+
return _ResolvedAdapters(
|
|
263
|
+
sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# 3. Default fallback
|
|
267
|
+
for engine in self._engines:
|
|
268
|
+
if storage == "sql" and engine.is_default_sql:
|
|
269
|
+
return _ResolvedAdapters(
|
|
270
|
+
sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
|
|
271
|
+
)
|
|
272
|
+
if storage == "nosql" and engine.is_default_nosql:
|
|
273
|
+
return _ResolvedAdapters(
|
|
274
|
+
sql=engine.sql(), nosql=engine.nosql(), engine_name=engine.name
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
raise AdapterConfigurationError(f"No engine for model='{model_name}' storage='{storage}'")
|
|
278
|
+
|
|
279
|
+
def _adapters_for(
|
|
280
|
+
self, model: Union[type, str], meta: ModelMeta, override: Optional[EngineOverride] = None
|
|
281
|
+
) -> _ResolvedAdapters:
|
|
282
|
+
name = _model_name(model)
|
|
283
|
+
if override and override.force_sql:
|
|
284
|
+
storage = "sql"
|
|
285
|
+
elif override and override.force_nosql:
|
|
286
|
+
storage = "nosql"
|
|
287
|
+
else:
|
|
288
|
+
storage = "sql" if (meta.storage == "sql" and meta.table) else "nosql"
|
|
289
|
+
return self._resolve_adapters(name, storage, override)
|
|
290
|
+
|
|
291
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
292
|
+
# ENGINE MANAGEMENT
|
|
293
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
def register_engine(self, engine: EngineConfig) -> None:
|
|
296
|
+
if engine.name in self._engine_by_name:
|
|
297
|
+
self._engines = [e for e in self._engines if e.name != engine.name]
|
|
298
|
+
self._engines.append(engine)
|
|
299
|
+
self._engine_by_name[engine.name] = engine
|
|
300
|
+
|
|
301
|
+
def unregister_engine(self, name: str) -> None:
|
|
302
|
+
if name not in self._engine_by_name:
|
|
303
|
+
raise AdapterConfigurationError(f"Engine '{name}' not registered.")
|
|
304
|
+
self._engines = [e for e in self._engines if e.name != name]
|
|
305
|
+
del self._engine_by_name[name]
|
|
306
|
+
|
|
307
|
+
def get_engine(self, name: str) -> EngineConfig:
|
|
308
|
+
if name not in self._engine_by_name:
|
|
309
|
+
raise AdapterConfigurationError(f"Engine '{name}' not found.")
|
|
310
|
+
return self._engine_by_name[name]
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def _sql(self) -> PostgreSQLAdapter:
|
|
314
|
+
for e in self._engines:
|
|
315
|
+
if e.is_default_sql:
|
|
316
|
+
return e.sql()
|
|
317
|
+
return self._engines[0].sql()
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def _nosql(self) -> NoSQLKVAdapter:
|
|
321
|
+
for e in self._engines:
|
|
322
|
+
if e.is_default_nosql:
|
|
323
|
+
return e.nosql()
|
|
324
|
+
return self._engines[0].nosql()
|
|
325
|
+
|
|
326
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
327
|
+
# HELPERS
|
|
328
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
def _inject_audit_fields(self, data: JsonDict, is_create: bool = False) -> JsonDict:
|
|
331
|
+
data = dict(data)
|
|
332
|
+
actor_id = AuditContext.actor_id.get()
|
|
333
|
+
now = datetime.utcnow().isoformat()
|
|
334
|
+
if is_create:
|
|
335
|
+
data.setdefault("created_at", now)
|
|
336
|
+
if actor_id:
|
|
337
|
+
data.setdefault("created_by", actor_id)
|
|
338
|
+
data.setdefault("updated_at", now)
|
|
339
|
+
if actor_id:
|
|
340
|
+
data.setdefault("updated_by", actor_id)
|
|
341
|
+
return data
|
|
342
|
+
|
|
343
|
+
def _apply_soft_delete_filter(self, query: Optional[Lookup]) -> Lookup:
|
|
344
|
+
if not self._soft_delete:
|
|
345
|
+
return query or {}
|
|
346
|
+
result = dict(query or {})
|
|
347
|
+
result.setdefault("deleted_at", None)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
def _audit_safe(
|
|
351
|
+
self,
|
|
352
|
+
*,
|
|
353
|
+
action: str,
|
|
354
|
+
model: Union[type, str],
|
|
355
|
+
entity_id: Optional[Any],
|
|
356
|
+
meta: ModelMeta,
|
|
357
|
+
success: bool,
|
|
358
|
+
before: Optional[JsonDict],
|
|
359
|
+
after: Optional[JsonDict],
|
|
360
|
+
error: Optional[str],
|
|
361
|
+
engine_name: Optional[str] = None,
|
|
362
|
+
) -> None:
|
|
363
|
+
if not self._audit:
|
|
364
|
+
return
|
|
365
|
+
try:
|
|
366
|
+
changed = None
|
|
367
|
+
if before and after:
|
|
368
|
+
changed = [
|
|
369
|
+
k for k in set(before) | set(after) if before.get(k) != after.get(k)
|
|
370
|
+
] or None
|
|
371
|
+
self._audit.record(
|
|
372
|
+
action=action,
|
|
373
|
+
model=_model_name(model),
|
|
374
|
+
entity_id=str(entity_id) if entity_id else None,
|
|
375
|
+
storage_type=meta.storage,
|
|
376
|
+
provider=engine_name or self._provider_name,
|
|
377
|
+
success=success,
|
|
378
|
+
before=before,
|
|
379
|
+
after=after,
|
|
380
|
+
changed_fields=changed,
|
|
381
|
+
error=error,
|
|
382
|
+
)
|
|
383
|
+
except Exception as exc:
|
|
384
|
+
logger.error("Audit recording failed: %s", exc)
|
|
385
|
+
|
|
386
|
+
def _run(self, fn: Callable[[], Any]) -> Any:
|
|
387
|
+
return fn()
|
|
388
|
+
|
|
389
|
+
def _is_sql(self, meta: ModelMeta, override: Optional[EngineOverride] = None) -> bool:
|
|
390
|
+
if override and override.force_sql:
|
|
391
|
+
return True
|
|
392
|
+
if override and override.force_nosql:
|
|
393
|
+
return False
|
|
394
|
+
return meta.storage == "sql" and bool(meta.table)
|
|
395
|
+
|
|
396
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
397
|
+
# CREATE
|
|
398
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
def create(
|
|
401
|
+
self,
|
|
402
|
+
model: Union[type, str],
|
|
403
|
+
data: JsonDict,
|
|
404
|
+
*,
|
|
405
|
+
engine_override: Optional[EngineOverride] = None,
|
|
406
|
+
) -> JsonDict:
|
|
407
|
+
meta = _extract_meta(model)
|
|
408
|
+
name = _model_name(model)
|
|
409
|
+
data = self._inject_audit_fields(data, is_create=True)
|
|
410
|
+
|
|
411
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
412
|
+
if self.encryption and encrypted_fields:
|
|
413
|
+
data = self.encryption.encrypt_fields(data, encrypted_fields)
|
|
414
|
+
|
|
415
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
416
|
+
after_plain = None
|
|
417
|
+
success = False
|
|
418
|
+
error: Optional[str] = None
|
|
419
|
+
entity_id: Optional[Any] = None
|
|
420
|
+
|
|
421
|
+
def _op() -> JsonDict:
|
|
422
|
+
nonlocal after_plain, success, entity_id
|
|
423
|
+
if self._is_sql(meta, engine_override):
|
|
424
|
+
try:
|
|
425
|
+
result = adapters.sql.insert(meta.table, data)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
if not _is_unique_violation(exc):
|
|
428
|
+
raise
|
|
429
|
+
conflict_cols = _parse_unique_violation_columns(exc)
|
|
430
|
+
logger.warning(
|
|
431
|
+
"insert %s hit unique violation on %s — record already exists, skipping",
|
|
432
|
+
meta.table, conflict_cols,
|
|
433
|
+
)
|
|
434
|
+
# Record already exists — fetch and return it so the caller gets a valid result
|
|
435
|
+
try:
|
|
436
|
+
if conflict_cols and all(c in data for c in conflict_cols):
|
|
437
|
+
where = {c: data[c] for c in conflict_cols}
|
|
438
|
+
existing = adapters.sql.select(meta.table, where, limit=1)
|
|
439
|
+
result = existing[0] if existing else data
|
|
440
|
+
else:
|
|
441
|
+
result = data
|
|
442
|
+
except Exception:
|
|
443
|
+
result = data
|
|
444
|
+
entity_id = result.get("id")
|
|
445
|
+
after_plain = result
|
|
446
|
+
success = True
|
|
447
|
+
if self._enable_cache and self._cache:
|
|
448
|
+
self._cache.invalidate(name)
|
|
449
|
+
return after_plain
|
|
450
|
+
else:
|
|
451
|
+
result = adapters.nosql.put(
|
|
452
|
+
model if isinstance(model, type)
|
|
453
|
+
else type(name, (), {"__polydb__": meta.__dict__}),
|
|
454
|
+
data,
|
|
455
|
+
)
|
|
456
|
+
entity_id = result.get("id")
|
|
457
|
+
after_plain = result
|
|
458
|
+
if self.encryption and encrypted_fields:
|
|
459
|
+
after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
|
|
460
|
+
success = True
|
|
461
|
+
if self._enable_cache and self._cache:
|
|
462
|
+
self._cache.invalidate(name)
|
|
463
|
+
return after_plain
|
|
464
|
+
try:
|
|
465
|
+
monitor = (
|
|
466
|
+
PerformanceMonitor(self.metrics, "create", name, None) if self.metrics else None
|
|
467
|
+
)
|
|
468
|
+
if monitor:
|
|
469
|
+
with monitor as m:
|
|
470
|
+
result = self._run(_op)
|
|
471
|
+
m.rows_affected = 1
|
|
472
|
+
return result
|
|
473
|
+
return self._run(_op)
|
|
474
|
+
except Exception as exc:
|
|
475
|
+
error = str(exc)
|
|
476
|
+
raise
|
|
477
|
+
finally:
|
|
478
|
+
self._audit_safe(
|
|
479
|
+
action="create",
|
|
480
|
+
model=model,
|
|
481
|
+
entity_id=entity_id,
|
|
482
|
+
meta=meta,
|
|
483
|
+
success=success,
|
|
484
|
+
before=None,
|
|
485
|
+
after=after_plain,
|
|
486
|
+
error=error,
|
|
487
|
+
engine_name=adapters.engine_name,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
491
|
+
# READ
|
|
492
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
def read(
|
|
495
|
+
self,
|
|
496
|
+
model: Union[type, str],
|
|
497
|
+
query: Optional[Lookup] = None,
|
|
498
|
+
*,
|
|
499
|
+
limit: Optional[int] = None,
|
|
500
|
+
offset: Optional[int] = None,
|
|
501
|
+
no_cache: bool = False,
|
|
502
|
+
cache_ttl: Optional[int] = None,
|
|
503
|
+
include_deleted: bool = False,
|
|
504
|
+
engine_override: Optional[EngineOverride] = None,
|
|
505
|
+
) -> List[JsonDict]:
|
|
506
|
+
name = _model_name(model)
|
|
507
|
+
meta = _extract_meta(model)
|
|
508
|
+
|
|
509
|
+
if self._soft_delete and not include_deleted:
|
|
510
|
+
query = self._apply_soft_delete_filter(query)
|
|
511
|
+
|
|
512
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
513
|
+
use_external_cache = self._enable_cache and self._cache and getattr(meta, "cache", False)
|
|
514
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
515
|
+
|
|
516
|
+
def _op() -> List[JsonDict]:
|
|
517
|
+
if self._is_sql(meta, engine_override):
|
|
518
|
+
raw = adapters.sql.select(meta.table, query, limit=limit, offset=offset)
|
|
519
|
+
else:
|
|
520
|
+
cls = (
|
|
521
|
+
model
|
|
522
|
+
if isinstance(model, type)
|
|
523
|
+
else type(name, (), {"__polydb__": meta.__dict__})
|
|
524
|
+
)
|
|
525
|
+
raw = adapters.nosql.query(
|
|
526
|
+
cls, query=query, limit=limit, no_cache=no_cache or bool(use_external_cache)
|
|
527
|
+
)
|
|
528
|
+
if self.encryption and encrypted_fields:
|
|
529
|
+
raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
|
|
530
|
+
if self._cache and use_external_cache and not no_cache:
|
|
531
|
+
ttl = cache_ttl or getattr(meta, "cache_ttl", 300)
|
|
532
|
+
self._cache.set(name, query or {}, raw, ttl)
|
|
533
|
+
return raw
|
|
534
|
+
|
|
535
|
+
# Check external cache first
|
|
536
|
+
if self._cache and use_external_cache and not no_cache:
|
|
537
|
+
cached = self._cache.get(name, query or {})
|
|
538
|
+
if cached is not None:
|
|
539
|
+
return cached
|
|
540
|
+
|
|
541
|
+
monitor = PerformanceMonitor(self.metrics, "read", name, None) if self.metrics else None
|
|
542
|
+
if monitor:
|
|
543
|
+
with monitor as m:
|
|
544
|
+
rows = self._run(_op)
|
|
545
|
+
m.rows_returned = len(rows)
|
|
546
|
+
return rows
|
|
547
|
+
return self._run(_op)
|
|
548
|
+
|
|
549
|
+
def read_one(
|
|
550
|
+
self,
|
|
551
|
+
model: Union[type, str],
|
|
552
|
+
query: Lookup,
|
|
553
|
+
*,
|
|
554
|
+
no_cache: bool = False,
|
|
555
|
+
include_deleted: bool = False,
|
|
556
|
+
engine_override: Optional[EngineOverride] = None,
|
|
557
|
+
) -> Optional[JsonDict]:
|
|
558
|
+
rows = self.read(
|
|
559
|
+
model,
|
|
560
|
+
query=query,
|
|
561
|
+
limit=1,
|
|
562
|
+
no_cache=no_cache,
|
|
563
|
+
include_deleted=include_deleted,
|
|
564
|
+
engine_override=engine_override,
|
|
565
|
+
)
|
|
566
|
+
return rows[0] if rows else None
|
|
567
|
+
|
|
568
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
569
|
+
# UPDATE
|
|
570
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
def update(
|
|
573
|
+
self,
|
|
574
|
+
model: Union[type, str],
|
|
575
|
+
entity_id: Union[Any, Lookup],
|
|
576
|
+
data: JsonDict,
|
|
577
|
+
*,
|
|
578
|
+
etag: Optional[str] = None,
|
|
579
|
+
replace: bool = False,
|
|
580
|
+
engine_override: Optional[EngineOverride] = None,
|
|
581
|
+
) -> JsonDict:
|
|
582
|
+
name = _model_name(model)
|
|
583
|
+
meta = _extract_meta(model)
|
|
584
|
+
data = self._inject_audit_fields(data, is_create=False)
|
|
585
|
+
|
|
586
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
587
|
+
if self.encryption and encrypted_fields:
|
|
588
|
+
data = self.encryption.encrypt_fields(data, [f for f in encrypted_fields if f in data])
|
|
589
|
+
|
|
590
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
591
|
+
before = self.read_one(
|
|
592
|
+
model,
|
|
593
|
+
{"id": entity_id} if not isinstance(entity_id, dict) else entity_id,
|
|
594
|
+
no_cache=True,
|
|
595
|
+
include_deleted=True,
|
|
596
|
+
engine_override=engine_override,
|
|
597
|
+
)
|
|
598
|
+
after_plain = None
|
|
599
|
+
success = False
|
|
600
|
+
error: Optional[str] = None
|
|
601
|
+
|
|
602
|
+
def _op() -> JsonDict:
|
|
603
|
+
nonlocal after_plain, success
|
|
604
|
+
if self._is_sql(meta, engine_override):
|
|
605
|
+
result = adapters.sql.update(meta.table, entity_id, data)
|
|
606
|
+
else:
|
|
607
|
+
pkey = data.get("PartitionKey") or data.get("partition_key") or data.get("pk")
|
|
608
|
+
en_id = entity_id
|
|
609
|
+
if not pkey and before:
|
|
610
|
+
pkey = (
|
|
611
|
+
before.get("PartitionKey")
|
|
612
|
+
or before.get("partition_key")
|
|
613
|
+
or before.get("pk")
|
|
614
|
+
)
|
|
615
|
+
if pkey:
|
|
616
|
+
if isinstance(en_id, dict):
|
|
617
|
+
en_pk = (
|
|
618
|
+
en_id.get("PartitionKey")
|
|
619
|
+
or en_id.get("partition_key")
|
|
620
|
+
or en_id.get("pk")
|
|
621
|
+
)
|
|
622
|
+
if not en_pk:
|
|
623
|
+
en_id["partition_key"] = pkey
|
|
624
|
+
elif isinstance(en_id, str):
|
|
625
|
+
en_id = {"partition_key": pkey, "id": entity_id}
|
|
626
|
+
|
|
627
|
+
cls = (
|
|
628
|
+
model
|
|
629
|
+
if isinstance(model, type)
|
|
630
|
+
else type(name, (), {"__polydb__": meta.__dict__})
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
result = adapters.nosql.patch(cls, en_id, data, etag=etag, replace=replace)
|
|
634
|
+
after_plain = result
|
|
635
|
+
if self.encryption and encrypted_fields:
|
|
636
|
+
after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
|
|
637
|
+
success = True
|
|
638
|
+
if self._enable_cache and self._cache:
|
|
639
|
+
self._cache.invalidate(name)
|
|
640
|
+
return after_plain
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
monitor = (
|
|
644
|
+
PerformanceMonitor(self.metrics, "update", name, None) if self.metrics else None
|
|
645
|
+
)
|
|
646
|
+
if monitor:
|
|
647
|
+
with monitor as m:
|
|
648
|
+
result = self._run(_op)
|
|
649
|
+
m.rows_affected = 1
|
|
650
|
+
return result
|
|
651
|
+
return self._run(_op)
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
error = str(exc)
|
|
654
|
+
raise
|
|
655
|
+
finally:
|
|
656
|
+
self._audit_safe(
|
|
657
|
+
action="update",
|
|
658
|
+
model=model,
|
|
659
|
+
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
660
|
+
meta=meta,
|
|
661
|
+
success=success,
|
|
662
|
+
before=before,
|
|
663
|
+
after=after_plain,
|
|
664
|
+
error=error,
|
|
665
|
+
engine_name=adapters.engine_name,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
669
|
+
# UPSERT
|
|
670
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
def upsert(
|
|
673
|
+
self,
|
|
674
|
+
model: Union[type, str],
|
|
675
|
+
data: JsonDict,
|
|
676
|
+
*,
|
|
677
|
+
replace: bool = False,
|
|
678
|
+
engine_override: Optional[EngineOverride] = None,
|
|
679
|
+
) -> JsonDict:
|
|
680
|
+
name = _model_name(model)
|
|
681
|
+
meta = _extract_meta(model)
|
|
682
|
+
data = self._inject_audit_fields(data, is_create=True)
|
|
683
|
+
|
|
684
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
685
|
+
if self.encryption and encrypted_fields:
|
|
686
|
+
data = self.encryption.encrypt_fields(data, encrypted_fields)
|
|
687
|
+
|
|
688
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
689
|
+
after_plain = None
|
|
690
|
+
success = False
|
|
691
|
+
error: Optional[str] = None
|
|
692
|
+
|
|
693
|
+
def _op() -> JsonDict:
|
|
694
|
+
nonlocal after_plain, success
|
|
695
|
+
if self._is_sql(meta, engine_override):
|
|
696
|
+
result = adapters.sql.upsert(meta.table, data)
|
|
697
|
+
else:
|
|
698
|
+
cls = (
|
|
699
|
+
model
|
|
700
|
+
if isinstance(model, type)
|
|
701
|
+
else type(name, (), {"__polydb__": meta.__dict__})
|
|
702
|
+
)
|
|
703
|
+
result = adapters.nosql.upsert(cls, data, replace=replace)
|
|
704
|
+
after_plain = result
|
|
705
|
+
if self.encryption and encrypted_fields:
|
|
706
|
+
after_plain = self.encryption.decrypt_fields(result, encrypted_fields)
|
|
707
|
+
success = True
|
|
708
|
+
if self._enable_cache and self._cache:
|
|
709
|
+
self._cache.invalidate(name)
|
|
710
|
+
return after_plain
|
|
711
|
+
|
|
712
|
+
try:
|
|
713
|
+
monitor = (
|
|
714
|
+
PerformanceMonitor(self.metrics, "upsert", name, None) if self.metrics else None
|
|
715
|
+
)
|
|
716
|
+
if monitor:
|
|
717
|
+
with monitor as m:
|
|
718
|
+
result = self._run(_op)
|
|
719
|
+
m.rows_affected = 1
|
|
720
|
+
return result
|
|
721
|
+
return self._run(_op)
|
|
722
|
+
except Exception as exc:
|
|
723
|
+
error = str(exc)
|
|
724
|
+
raise
|
|
725
|
+
finally:
|
|
726
|
+
self._audit_safe(
|
|
727
|
+
action="upsert",
|
|
728
|
+
model=model,
|
|
729
|
+
entity_id=None,
|
|
730
|
+
meta=meta,
|
|
731
|
+
success=success,
|
|
732
|
+
before=None,
|
|
733
|
+
after=after_plain,
|
|
734
|
+
error=error,
|
|
735
|
+
engine_name=adapters.engine_name,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
739
|
+
# DELETE
|
|
740
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
def delete(
|
|
743
|
+
self,
|
|
744
|
+
model: Union[type, str],
|
|
745
|
+
entity_id: Union[Any, Lookup],
|
|
746
|
+
*,
|
|
747
|
+
etag: Optional[str] = None,
|
|
748
|
+
hard: bool = False,
|
|
749
|
+
engine_override: Optional[EngineOverride] = None,
|
|
750
|
+
) -> JsonDict:
|
|
751
|
+
meta = _extract_meta(model)
|
|
752
|
+
name = _model_name(model)
|
|
753
|
+
|
|
754
|
+
if self._soft_delete and not hard:
|
|
755
|
+
return self.update(
|
|
756
|
+
model,
|
|
757
|
+
entity_id,
|
|
758
|
+
{
|
|
759
|
+
"deleted_at": datetime.utcnow().isoformat(),
|
|
760
|
+
"deleted_by": AuditContext.actor_id.get(),
|
|
761
|
+
},
|
|
762
|
+
engine_override=engine_override,
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
766
|
+
before = self.read_one(
|
|
767
|
+
model,
|
|
768
|
+
{"id": entity_id} if not isinstance(entity_id, dict) else entity_id,
|
|
769
|
+
no_cache=True,
|
|
770
|
+
include_deleted=True,
|
|
771
|
+
engine_override=engine_override,
|
|
772
|
+
)
|
|
773
|
+
success = False
|
|
774
|
+
error: Optional[str] = None
|
|
775
|
+
|
|
776
|
+
def _op() -> JsonDict:
|
|
777
|
+
nonlocal success
|
|
778
|
+
if self._is_sql(meta, engine_override):
|
|
779
|
+
result = adapters.sql.delete(meta.table, entity_id)
|
|
780
|
+
else:
|
|
781
|
+
cls = (
|
|
782
|
+
model
|
|
783
|
+
if isinstance(model, type)
|
|
784
|
+
else type(name, (), {"__polydb__": meta.__dict__})
|
|
785
|
+
)
|
|
786
|
+
result = adapters.nosql.delete(cls, entity_id, etag=etag)
|
|
787
|
+
success = True
|
|
788
|
+
if self._enable_cache and self._cache:
|
|
789
|
+
self._cache.invalidate(name)
|
|
790
|
+
return result
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
monitor = (
|
|
794
|
+
PerformanceMonitor(self.metrics, "delete", name, None) if self.metrics else None
|
|
795
|
+
)
|
|
796
|
+
if monitor:
|
|
797
|
+
with monitor as m:
|
|
798
|
+
result = self._run(_op)
|
|
799
|
+
m.rows_affected = 1
|
|
800
|
+
return result
|
|
801
|
+
return self._run(_op)
|
|
802
|
+
except Exception as exc:
|
|
803
|
+
error = str(exc)
|
|
804
|
+
raise
|
|
805
|
+
finally:
|
|
806
|
+
self._audit_safe(
|
|
807
|
+
action="delete",
|
|
808
|
+
model=model,
|
|
809
|
+
entity_id=entity_id if not isinstance(entity_id, dict) else None,
|
|
810
|
+
meta=meta,
|
|
811
|
+
success=success,
|
|
812
|
+
before=before,
|
|
813
|
+
after=None,
|
|
814
|
+
error=error,
|
|
815
|
+
engine_name=adapters.engine_name,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
819
|
+
# QUERY (LINQ-style)
|
|
820
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
821
|
+
|
|
822
|
+
def query_linq(
|
|
823
|
+
self,
|
|
824
|
+
model: Union[type, str],
|
|
825
|
+
builder: QueryBuilder,
|
|
826
|
+
*,
|
|
827
|
+
engine_override: Optional[EngineOverride] = None,
|
|
828
|
+
) -> Union[List[JsonDict], int]:
|
|
829
|
+
name = _model_name(model)
|
|
830
|
+
meta = _extract_meta(model)
|
|
831
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
832
|
+
|
|
833
|
+
def _op():
|
|
834
|
+
if self._is_sql(meta, engine_override):
|
|
835
|
+
return adapters.sql.query_linq(meta.table, builder)
|
|
836
|
+
cls = (
|
|
837
|
+
model if isinstance(model, type) else type(name, (), {"__polydb__": meta.__dict__})
|
|
838
|
+
)
|
|
839
|
+
return adapters.nosql.query_linq(cls, builder)
|
|
840
|
+
|
|
841
|
+
monitor = (
|
|
842
|
+
PerformanceMonitor(self.metrics, "query_linq", name, None) if self.metrics else None
|
|
843
|
+
)
|
|
844
|
+
if monitor:
|
|
845
|
+
with monitor as m:
|
|
846
|
+
result = self._run(_op)
|
|
847
|
+
if isinstance(result, list):
|
|
848
|
+
m.rows_returned = len(result)
|
|
849
|
+
return result
|
|
850
|
+
return self._run(_op)
|
|
851
|
+
|
|
852
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
853
|
+
# PAGINATION
|
|
854
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
def read_page(
|
|
857
|
+
self,
|
|
858
|
+
model: Union[type, str],
|
|
859
|
+
query: Lookup,
|
|
860
|
+
*,
|
|
861
|
+
page_size: int = 100,
|
|
862
|
+
continuation_token: Optional[str] = None,
|
|
863
|
+
include_deleted: bool = False,
|
|
864
|
+
engine_override: Optional[EngineOverride] = None,
|
|
865
|
+
) -> Optional[Tuple[List[JsonDict], Optional[str]]]:
|
|
866
|
+
name = _model_name(model)
|
|
867
|
+
meta = _extract_meta(model)
|
|
868
|
+
|
|
869
|
+
if self._soft_delete and not include_deleted:
|
|
870
|
+
query = self._apply_soft_delete_filter(query)
|
|
871
|
+
|
|
872
|
+
adapters = self._adapters_for(model, meta, engine_override)
|
|
873
|
+
encrypted_fields = getattr(meta, "encrypted_fields", [])
|
|
874
|
+
|
|
875
|
+
def _op() -> Tuple[List[JsonDict], Optional[str]]:
|
|
876
|
+
if self._is_sql(meta, engine_override):
|
|
877
|
+
raw, token = adapters.sql.select_page(
|
|
878
|
+
meta.table, query, page_size, continuation_token
|
|
879
|
+
)
|
|
880
|
+
else:
|
|
881
|
+
cls = (
|
|
882
|
+
model
|
|
883
|
+
if isinstance(model, type)
|
|
884
|
+
else type(name, (), {"__polydb__": meta.__dict__})
|
|
885
|
+
)
|
|
886
|
+
raw, token = adapters.nosql.query_page(cls, query, page_size, continuation_token)
|
|
887
|
+
if self.encryption and encrypted_fields:
|
|
888
|
+
raw = [self.encryption.decrypt_fields(r, encrypted_fields) for r in raw]
|
|
889
|
+
return raw, token
|
|
890
|
+
|
|
891
|
+
monitor = (
|
|
892
|
+
PerformanceMonitor(self.metrics, "read_page", name, None) if self.metrics else None
|
|
893
|
+
)
|
|
894
|
+
if monitor:
|
|
895
|
+
with monitor as m:
|
|
896
|
+
result = self._run(_op)
|
|
897
|
+
m.rows_returned = len(result[0])
|
|
898
|
+
return result
|
|
899
|
+
return self._run(_op)
|
|
900
|
+
|
|
901
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
902
|
+
# BLOB STORAGE
|
|
903
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
904
|
+
|
|
905
|
+
def upload_blob(
|
|
906
|
+
self,
|
|
907
|
+
key: str,
|
|
908
|
+
data: bytes,
|
|
909
|
+
*,
|
|
910
|
+
file_name: Optional[str] = None,
|
|
911
|
+
media_type: Optional[str] = None,
|
|
912
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
913
|
+
storage_name: str = "azure",
|
|
914
|
+
container_name: Optional[str] = None,
|
|
915
|
+
) -> str:
|
|
916
|
+
storage = self._engines[0].cloud_factory.get_object_storage(
|
|
917
|
+
storage_name, container_name=container_name
|
|
918
|
+
)
|
|
919
|
+
return storage.put(
|
|
920
|
+
key=key,
|
|
921
|
+
data=data,
|
|
922
|
+
fileName=file_name or key,
|
|
923
|
+
optimize=True,
|
|
924
|
+
media_type=media_type,
|
|
925
|
+
metadata=metadata or {},
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
def download_blob(
|
|
929
|
+
self, key: str, *, storage_name: str = "azure", container_name: Optional[str] = None
|
|
930
|
+
) -> Optional[bytes]:
|
|
931
|
+
storage = self._engines[0].cloud_factory.get_object_storage(
|
|
932
|
+
storage_name, container_name=container_name
|
|
933
|
+
)
|
|
934
|
+
return storage.get(key)
|
|
935
|
+
|
|
936
|
+
def delete_blob(
|
|
937
|
+
self, key: str, *, storage_name: str = "azure", container_name: Optional[str] = None
|
|
938
|
+
) -> bool:
|
|
939
|
+
storage = self._engines[0].cloud_factory.get_object_storage(
|
|
940
|
+
storage_name, container_name=container_name
|
|
941
|
+
)
|
|
942
|
+
return storage.delete(key)
|
|
943
|
+
|
|
944
|
+
def list_blob(
|
|
945
|
+
self, prefix: str = "", *, storage_name: str = "azure", container_name: Optional[str] = None
|
|
946
|
+
) -> List[str]:
|
|
947
|
+
storage = self._engines[0].cloud_factory.get_object_storage(
|
|
948
|
+
storage_name, container_name=container_name
|
|
949
|
+
)
|
|
950
|
+
return storage.list(prefix)
|
|
951
|
+
|
|
952
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
953
|
+
# QUEUE
|
|
954
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
955
|
+
|
|
956
|
+
def send_queue(
|
|
957
|
+
self,
|
|
958
|
+
message: Dict[str, Any],
|
|
959
|
+
*,
|
|
960
|
+
queue_name: str = "default",
|
|
961
|
+
adapter_name: str = "azure_queue",
|
|
962
|
+
) -> str:
|
|
963
|
+
queue = self._engines[0].cloud_factory.get_queue(adapter_name)
|
|
964
|
+
return queue.send(message=message, queue_name=queue_name)
|
|
965
|
+
|
|
966
|
+
def receive_queue(
|
|
967
|
+
self,
|
|
968
|
+
*,
|
|
969
|
+
queue_name: str = "default",
|
|
970
|
+
max_messages: int = 10,
|
|
971
|
+
adapter_name: str = "azure_queue",
|
|
972
|
+
) -> List[Dict[str, Any]]:
|
|
973
|
+
queue = self._engines[0].cloud_factory.get_queue(adapter_name)
|
|
974
|
+
return queue.receive(queue_name=queue_name, max_messages=max_messages)
|
|
975
|
+
|
|
976
|
+
def ack_queue(
|
|
977
|
+
self, ack_id: str, *, queue_name: str = "default", adapter_name: str = "azure_queue"
|
|
978
|
+
) -> bool:
|
|
979
|
+
queue = self._engines[0].cloud_factory.get_queue(adapter_name)
|
|
980
|
+
return (
|
|
981
|
+
queue.ack(ack_id, queue_name)
|
|
982
|
+
if hasattr(queue, "ack")
|
|
983
|
+
else queue.delete(ack_id, queue_name)
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
def delete_queue(
|
|
987
|
+
self,
|
|
988
|
+
message_id: str,
|
|
989
|
+
*,
|
|
990
|
+
queue_name: str = "default",
|
|
991
|
+
pop_receipt: Optional[str] = None,
|
|
992
|
+
adapter_name: str = "azure_queue",
|
|
993
|
+
) -> bool:
|
|
994
|
+
queue = self._engines[0].cloud_factory.get_queue(adapter_name)
|
|
995
|
+
return (
|
|
996
|
+
queue.delete(message_id, queue_name, pop_receipt)
|
|
997
|
+
if pop_receipt
|
|
998
|
+
else queue.delete(message_id, queue_name)
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1002
|
+
# FILE STORAGE
|
|
1003
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1004
|
+
|
|
1005
|
+
def write_file(
|
|
1006
|
+
self, path: str, data: Union[bytes, str], *, adapter_name: str = "files"
|
|
1007
|
+
) -> bool:
|
|
1008
|
+
files = self._engines[0].cloud_factory.get_files(adapter_name)
|
|
1009
|
+
return files.write(path, data.encode() if isinstance(data, str) else data) # type: ignore
|
|
1010
|
+
|
|
1011
|
+
def read_file(self, path: str, *, adapter_name: str = "files") -> Optional[bytes]:
|
|
1012
|
+
files = self._engines[0].cloud_factory.get_files(adapter_name)
|
|
1013
|
+
return files.read(path) # type: ignore
|
|
1014
|
+
|
|
1015
|
+
def delete_file(self, path: str, *, adapter_name: str = "files") -> bool:
|
|
1016
|
+
files = self._engines[0].cloud_factory.get_files(adapter_name)
|
|
1017
|
+
return files.delete(path)
|
|
1018
|
+
|
|
1019
|
+
def list_files(self, directory: str = "", *, adapter_name: str = "files") -> List[str]:
|
|
1020
|
+
files = self._engines[0].cloud_factory.get_files(adapter_name)
|
|
1021
|
+
return files.list(directory)
|
|
1022
|
+
|
|
1023
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1024
|
+
# CACHE
|
|
1025
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1026
|
+
|
|
1027
|
+
def set_cache(self, model: str, key: Any, value: Any, ttl: int = 300) -> None:
|
|
1028
|
+
if self._cache:
|
|
1029
|
+
self._cache.set(model, key, value, ttl)
|
|
1030
|
+
|
|
1031
|
+
def get_cache(self, model: str, key: Any) -> Optional[Any]:
|
|
1032
|
+
return self._cache.get(model, key) if self._cache else None
|
|
1033
|
+
|
|
1034
|
+
def invalidate_cache(self, model: str, key: Optional[Any] = None) -> None:
|
|
1035
|
+
if not self._cache:
|
|
1036
|
+
return
|
|
1037
|
+
if key:
|
|
1038
|
+
self._cache.invalidate(model, key)
|
|
1039
|
+
else:
|
|
1040
|
+
self._cache.clear()
|
polydb/retry.py
CHANGED
|
@@ -3,10 +3,33 @@
|
|
|
3
3
|
Retry logic with exponential backoff and metrics hooks
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import functools
|
|
6
7
|
import time
|
|
7
8
|
import logging
|
|
8
9
|
from functools import wraps
|
|
9
10
|
from typing import Callable, Optional, Tuple, Type
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_NON_RETRYABLE_MARKERS = (
|
|
14
|
+
"23505", # Postgres unique_violation
|
|
15
|
+
"23503", # Postgres foreign_key_violation
|
|
16
|
+
"23502", # Postgres not_null_violation
|
|
17
|
+
"23514", # Postgres check_violation
|
|
18
|
+
"duplicate key value violates",
|
|
19
|
+
"unique constraint",
|
|
20
|
+
"UniqueViolation",
|
|
21
|
+
"Duplicate entry",
|
|
22
|
+
"UNIQUE constraint failed",
|
|
23
|
+
"PropertyValueTooLarge",
|
|
24
|
+
"ResourceNotFound",
|
|
25
|
+
"InvalidArgument",
|
|
26
|
+
"AuthenticationFailed",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_non_retryable(exc: BaseException) -> bool:
|
|
31
|
+
s = str(exc)
|
|
32
|
+
return any(m in s for m in _NON_RETRYABLE_MARKERS)
|
|
10
33
|
|
|
11
34
|
|
|
12
35
|
# Metrics hooks for enterprise monitoring
|
{altcodepro_polydb_python-2.3.16.dist-info → altcodepro_polydb_python-2.3.18.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|