jasil 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. jasil/__init__.py +48 -0
  2. jasil/_core/__init__.py +5 -0
  3. jasil/_core/dialects.py +43 -0
  4. jasil/_core/identity.py +34 -0
  5. jasil/_core/limits.py +95 -0
  6. jasil/_core/network.py +199 -0
  7. jasil/_core/optional_deps.py +30 -0
  8. jasil/_core/pruning.py +58 -0
  9. jasil/_core/redis_clients.py +219 -0
  10. jasil/_core/registry.py +75 -0
  11. jasil/_core/sessions.py +27 -0
  12. jasil/_core/storage_keys.py +38 -0
  13. jasil/_core/threads.py +44 -0
  14. jasil/_core/timestamps.py +48 -0
  15. jasil/admin.py +124 -0
  16. jasil/backends/__init__.py +10 -0
  17. jasil/backends/clock_system.py +14 -0
  18. jasil/backends/events_inprocess.py +56 -0
  19. jasil/backends/events_redis.py +186 -0
  20. jasil/backends/geocoding_http.py +237 -0
  21. jasil/backends/lock_noop.py +17 -0
  22. jasil/backends/lock_pg.py +77 -0
  23. jasil/backends/state_memory.py +129 -0
  24. jasil/backends/state_redis.py +165 -0
  25. jasil/backends/storage_local.py +109 -0
  26. jasil/backends/storage_s3.py +120 -0
  27. jasil/capabilities.py +309 -0
  28. jasil/container.py +285 -0
  29. jasil/correlation.py +75 -0
  30. jasil/deps.py +67 -0
  31. jasil/event_log/__init__.py +1 -0
  32. jasil/event_log/crud.py +336 -0
  33. jasil/event_log/models.py +131 -0
  34. jasil/event_log/recorder.py +115 -0
  35. jasil/event_log/schema.py +101 -0
  36. jasil/event_versioning.py +154 -0
  37. jasil/events.py +148 -0
  38. jasil/jobs/__init__.py +10 -0
  39. jasil/jobs/backoff.py +51 -0
  40. jasil/jobs/crud.py +529 -0
  41. jasil/jobs/models.py +267 -0
  42. jasil/jobs/outbox.py +133 -0
  43. jasil/jobs/reconciliation.py +115 -0
  44. jasil/jobs/registry.py +95 -0
  45. jasil/jobs/relay.py +100 -0
  46. jasil/jobs/runner.py +185 -0
  47. jasil/jobs/schema.py +99 -0
  48. jasil/jobs/service.py +166 -0
  49. jasil/jobs/worker.py +89 -0
  50. jasil/lifecycle.py +72 -0
  51. jasil/migrations/__init__.py +156 -0
  52. jasil/migrations/env.py +64 -0
  53. jasil/migrations/script.py.mako +24 -0
  54. jasil/migrations/versions/rev0001_initial.py +242 -0
  55. jasil/orm.py +224 -0
  56. jasil/profile.py +135 -0
  57. jasil/providers.py +197 -0
  58. jasil/publisher.py +263 -0
  59. jasil/py.typed +0 -0
  60. jasil/retention.py +148 -0
  61. jasil/runtime.py +74 -0
  62. jasil/settings.py +247 -0
  63. jasil/subscribers.py +60 -0
  64. jasil/testing.py +131 -0
  65. jasil-0.1.0.dist-info/METADATA +221 -0
  66. jasil-0.1.0.dist-info/RECORD +68 -0
  67. jasil-0.1.0.dist-info/WHEEL +4 -0
  68. jasil-0.1.0.dist-info/licenses/LICENSE.md +7 -0
jasil/__init__.py ADDED
@@ -0,0 +1,48 @@
1
+ """Platform substrate: deployment profile, event envelope, and providers.
2
+
3
+ Public (pure) surface — safe to import anywhere; pulls in no backends:
4
+ - ``DeploymentProfile`` — the deployment shape.
5
+ - ``Event`` / ``new_event`` — the event envelope.
6
+ - ``StateProvider`` / ``StorageProvider`` / ``EventBusProvider`` /
7
+ ``LockProvider`` / ``ClockProvider`` — the capability providers.
8
+
9
+ The composition root (``Platform`` / ``build_platform``) lives in
10
+ ``jasil.container`` and is imported explicitly where the platform is
11
+ built (startup, ``deps``). Keeping it out of this ``__init__`` means importing
12
+ the pure profile/capabilities modules never drags the concrete backends in.
13
+ """
14
+
15
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
16
+ from importlib.metadata import version as _version
17
+
18
+ from jasil.events import Event, new_event
19
+ from jasil.profile import DeploymentProfile
20
+ from jasil.providers import (
21
+ ClockProvider,
22
+ EventBusProvider,
23
+ LockProvider,
24
+ StateBackendUnavailableError,
25
+ StateProvider,
26
+ StorageProvider,
27
+ TieredFailureOutcome,
28
+ )
29
+
30
+ try:
31
+ __version__ = _version("jasil")
32
+ except _PackageNotFoundError: # pragma: no cover - running from an unbuilt source tree
33
+ __version__ = "0.0.0+unknown"
34
+
35
+ __all__ = [
36
+ # Package metadata
37
+ "__version__",
38
+ "ClockProvider",
39
+ "DeploymentProfile",
40
+ "Event",
41
+ "EventBusProvider",
42
+ "LockProvider",
43
+ "StateBackendUnavailableError",
44
+ "StateProvider",
45
+ "StorageProvider",
46
+ "TieredFailureOutcome",
47
+ "new_event",
48
+ ]
@@ -0,0 +1,5 @@
1
+ """Vendored low-level helpers.
2
+
3
+ Private to JASIL: nothing here is part of the public API, and these modules must
4
+ not import from the substrate, the backends, or the jobs layer.
5
+ """
@@ -0,0 +1,43 @@
1
+ """Database-dialect capability checks.
2
+
3
+ JASIL's tables are portable across SQLite, PostgreSQL and MySQL, but the
4
+ concurrency primitives are not uniformly available. Rather than testing
5
+ ``dialect.name == "postgresql"`` at each call site — which silently downgrades
6
+ every other database — the checks live here.
7
+ """
8
+
9
+ from typing import Any
10
+
11
+ __all__ = ["supports_skip_locked"]
12
+
13
+
14
+ def supports_skip_locked(bind: Any) -> bool:
15
+ """Whether ``bind`` supports ``SELECT ... FOR UPDATE SKIP LOCKED``.
16
+
17
+ Used by the claim, relay and prune queries so concurrent workers take
18
+ disjoint batches instead of contending. Where it is unavailable the caller
19
+ falls back to a plain select, which stays *correct* for a single worker but
20
+ offers no protection against two workers claiming the same row — so this
21
+ must not report a false positive.
22
+
23
+ Args:
24
+ bind: The session's bind (an ``Engine`` or ``Connection``), or ``None``.
25
+
26
+ Returns:
27
+ True when the clause is supported. SQLite always returns False: it has
28
+ no row-level locking, and a single writer makes the clause unnecessary.
29
+ """
30
+ if bind is None:
31
+ return False
32
+ dialect = bind.dialect
33
+ if dialect.name == "postgresql":
34
+ return True
35
+ if dialect.name == "mysql":
36
+ # Populated on first connect; treat "unknown" as unsupported.
37
+ version = getattr(dialect, "server_version_info", None)
38
+ if not version:
39
+ return False
40
+ if getattr(dialect, "is_mariadb", False):
41
+ return version >= (10, 6)
42
+ return version >= (8, 0, 1)
43
+ return False
@@ -0,0 +1,34 @@
1
+ """Process identity for competing-consumer coordination.
2
+
3
+ A single helper so the durable-job worker and the Redis Streams consumer derive
4
+ their identifier the same way. The value distinguishes competing consumers
5
+ across replicas in logs, lease holders (``processing_jobs.locked_by``), and
6
+ Redis pending-entry lists.
7
+ """
8
+
9
+ import hashlib
10
+ import os
11
+ import socket
12
+
13
+ from jasil._core.limits import MAX_WORKER_ID_LENGTH
14
+
15
+
16
+ def process_identity() -> str:
17
+ """Return a stable per-process identifier that fits the lease column.
18
+
19
+ Uniqueness is the point: this value is what ``claim_jobs`` compares against
20
+ to decide whether *this* worker won a row, so two live processes sharing one
21
+ identity would run the same job twice. A long hostname therefore cannot
22
+ simply be clipped — the truncated form carries a digest of the full identity
23
+ instead, which stays distinct where a shared prefix would not.
24
+
25
+ Returns:
26
+ ``"{hostname}-{pid}"`` — unique per process, stable for its lifetime — or
27
+ a digest-suffixed form of it when that would exceed
28
+ :data:`~jasil._core.limits.MAX_WORKER_ID_LENGTH`.
29
+ """
30
+ identity = f"{socket.gethostname()}-{os.getpid()}"
31
+ if len(identity) <= MAX_WORKER_ID_LENGTH:
32
+ return identity
33
+ digest = hashlib.blake2s(identity.encode(), digest_size=8).hexdigest()
34
+ return f"{identity[: MAX_WORKER_ID_LENGTH - len(digest) - 1]}-{digest}"
jasil/_core/limits.py ADDED
@@ -0,0 +1,95 @@
1
+ """Bounds on the values JASIL persists.
2
+
3
+ Two shapes of bound, because the values differ in kind:
4
+
5
+ * :func:`check_length` **raises**. The envelope's ``event_id`` / ``event_type`` /
6
+ ``source`` and a durable ``subscriber_id`` are written by the producing code,
7
+ never by a user, so an over-long one is a bug to fix rather than a value to
8
+ clip — a truncated ``event_type`` routes to the wrong subscribers, or to none.
9
+ * :func:`fit_length` **truncates**, leaving a marker. Failure text, the joined
10
+ subscriber list, and a worker's identity are derived and diagnostic, so
11
+ clipping them loses nothing that matters while refusing would lose the record
12
+ itself. The marker is what stops a reader assuming the value is complete.
13
+
14
+ Either way the bound is applied where the value enters the system, so an
15
+ over-long value is one clear outcome instead of a truncation error on PostgreSQL
16
+ and MySQL, silent acceptance on SQLite, and — because the publish seam swallows
17
+ delivery failures — a lost event on all three.
18
+
19
+ **Every constant here is imported by the model declaring the column it bounds**,
20
+ so a width and the code protecting it cannot drift apart. The one pair that was
21
+ allowed to drift is what produced the bug recorded on
22
+ :data:`MAX_HANDLER_NAME_LENGTH`.
23
+ """
24
+
25
+ __all__ = [
26
+ "MAX_HANDLER_NAME_LENGTH",
27
+ "MAX_STORED_ERROR_LENGTH",
28
+ "MAX_WORKER_ID_LENGTH",
29
+ "check_length",
30
+ "fit_length",
31
+ ]
32
+
33
+ #: Cap on the failure text written to ``event_log.error_message`` and
34
+ #: ``processing_jobs.last_error``. Both are unbounded ``Text`` columns, so this
35
+ #: exists to stop one pathological exception (a driver dumping a whole query, a
36
+ #: deeply nested cause chain) from bloating every retry's row.
37
+ MAX_STORED_ERROR_LENGTH = 4000
38
+
39
+ #: Width of ``event_log.handler_name``, which holds the comma-joined list of every
40
+ #: subscriber that ran for one event — so its length grows with the subscriber
41
+ #: count and is unbounded from the writing layer's point of view. Overflowing it
42
+ #: made PostgreSQL reject the whole UPDATE, and because event-log writes are
43
+ #: deliberately best-effort (swallowed by the recorder so observability never
44
+ #: breaks processing) the failure was silent: the handlers had already run, so
45
+ #: the work completed while the row stayed ``published`` forever.
46
+ MAX_HANDLER_NAME_LENGTH = 500
47
+
48
+ #: Width of ``event_log.worker_id`` and ``processing_jobs.locked_by``, both of
49
+ #: which hold a process identity. That value derives from the machine's hostname
50
+ #: (see :func:`jasil._core.identity.process_identity`), so a deployment cannot shorten it
51
+ #: and the library has to guarantee the fit itself.
52
+ MAX_WORKER_ID_LENGTH = 100
53
+
54
+ _TRUNCATION_MARKER = "..."
55
+
56
+
57
+ def check_length(value: str, *, field: str, limit: int) -> None:
58
+ """Raise when ``value`` would not fit the ``limit``-character column for ``field``.
59
+
60
+ Args:
61
+ value: The identifier to check.
62
+ field: Its name, used in the error message.
63
+ limit: The character limit of the column it is stored in.
64
+
65
+ Raises:
66
+ ValueError: When ``value`` is longer than ``limit``. These identifiers are
67
+ written by the producing code, never by users, so an over-long one is
68
+ a bug to fix rather than a value to clip: a truncated ``event_type``
69
+ would route to the wrong subscribers, or to none at all.
70
+ """
71
+ if len(value) > limit:
72
+ raise ValueError(
73
+ f"{field} is {len(value)} characters, which exceeds the {limit}-character limit "
74
+ f"and would be rejected or truncated on write: {value[:limit]!r}..."
75
+ )
76
+
77
+
78
+ def fit_length(value: str | None, limit: int) -> str | None:
79
+ """Clamp ``value`` to ``limit`` characters, marking it when it had to be cut.
80
+
81
+ For the derived, diagnostic values :func:`check_length` would be the wrong
82
+ tool for: refusing one discards the very record being written.
83
+
84
+ Args:
85
+ value: The text about to be stored, or ``None``.
86
+ limit: The character limit of the column it is stored in.
87
+
88
+ Returns:
89
+ ``value`` unchanged when it is ``None`` or already fits, otherwise
90
+ exactly ``limit`` characters ending in ``...``, so a reader can tell the
91
+ value was cut rather than assume it is complete.
92
+ """
93
+ if value is None or len(value) <= limit:
94
+ return value
95
+ return value[: limit - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
jasil/_core/network.py ADDED
@@ -0,0 +1,199 @@
1
+ """SSRF guard for operator-configured outbound hosts.
2
+
3
+ Vendored from a host application's network helpers, trimmed to the outbound
4
+ half. The inbound half (proxy-aware client-IP extraction, trusted-proxy
5
+ resolution) is a web-framework concern and deliberately did not come across:
6
+ JASIL serves no requests, and keeping it out is what lets this module stay free
7
+ of ``fastapi``.
8
+
9
+ The allowlist is passed in rather than read from a global, so the host owns the
10
+ policy and the guard stays a pure function.
11
+ """
12
+
13
+ import ipaddress
14
+ import logging
15
+ import re
16
+ import socket
17
+ from collections.abc import Sequence
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # RFC 1123 hostname syntax: labels of 1-63 alphanumeric/hyphen characters,
22
+ # separated by dots. Hyphens may not start or end a label.
23
+ _HOSTNAME_RE = re.compile(
24
+ r"^(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)" # first label
25
+ r"(?:\.(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?))*$", # more
26
+ re.IGNORECASE,
27
+ )
28
+
29
+ # Reasons a destination is refused. Phrased to read correctly after a host value.
30
+ _UNRESOLVABLE = "hostname could not be resolved"
31
+ _UNPARSEABLE = "resolves to an unparseable address"
32
+ _NON_PUBLIC = "resolves to a non-public address"
33
+ _NOT_AN_AUTHORITY = "is not a bare host[:port] authority"
34
+
35
+
36
+ def _is_valid_hostname(value: str) -> bool:
37
+ """Return True when ``value`` is a syntactically valid RFC 1123 hostname.
38
+
39
+ Rejects values carrying URL schemes, ports, or other non-hostname characters
40
+ (e.g. ``caddy:8080`` or ``http://caddy``).
41
+
42
+ Args:
43
+ value: Candidate hostname to validate.
44
+
45
+ Returns:
46
+ True when ``value`` conforms to RFC 1123 hostname syntax.
47
+ """
48
+ return len(value) <= 253 and _HOSTNAME_RE.match(value) is not None
49
+
50
+
51
+ def _is_private_or_reserved(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
52
+ """Return True if ``addr`` belongs to any non-routable range.
53
+
54
+ Combines every "do not dial" predicate ``ipaddress`` exposes: private
55
+ (RFC1918, fc00::/7), loopback, link-local, multicast, unspecified, and
56
+ reserved. Any of these would let an attacker pivot to internal
57
+ infrastructure or a cloud metadata service.
58
+ """
59
+ return (
60
+ addr.is_private
61
+ or addr.is_loopback
62
+ or addr.is_link_local
63
+ or addr.is_multicast
64
+ or addr.is_unspecified
65
+ or addr.is_reserved
66
+ )
67
+
68
+
69
+ def _split_allowlist(
70
+ allowed_hosts: Sequence[str],
71
+ ) -> tuple[frozenset[str], tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]]:
72
+ """Classify allowlist entries into hostnames and IP networks."""
73
+ hosts: set[str] = set()
74
+ networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
75
+ for entry in allowed_hosts:
76
+ try:
77
+ networks.append(ipaddress.ip_network(entry, strict=False))
78
+ except ValueError:
79
+ hosts.add(entry.lower())
80
+ return frozenset(hosts), tuple(networks)
81
+
82
+
83
+ def _address_rejection_reason(
84
+ hostname: str,
85
+ *,
86
+ allowed_hosts: Sequence[str],
87
+ purpose: str | None = None,
88
+ ) -> str | None:
89
+ """Return why ``hostname`` must not be dialed, or None when every address is safe.
90
+
91
+ Resolves every A/AAAA record and requires all of them to be public unicast. A
92
+ single private/loopback/link-local answer rejects the host — this defends
93
+ against DNS rebinding, where an attacker-controlled name returns a public IP
94
+ on the first lookup and a private IP on the next.
95
+
96
+ Args:
97
+ hostname: The hostname to resolve, without a port.
98
+ allowed_hosts: Hostnames and CIDRs exempt from the address denylist.
99
+ purpose: Optional short tag identifying the outbound call, used only for
100
+ audit logging.
101
+
102
+ Returns:
103
+ A reason string, or ``None`` when the host may be dialed.
104
+ """
105
+ try:
106
+ infos = socket.getaddrinfo(hostname, None)
107
+ except socket.gaierror:
108
+ return _UNRESOLVABLE
109
+
110
+ exempt_hostnames, exempt_networks = _split_allowlist(allowed_hosts)
111
+ for info in infos:
112
+ ip_text = info[4][0]
113
+ try:
114
+ addr = ipaddress.ip_address(ip_text)
115
+ except ValueError:
116
+ # Defensive: a resolver answer we cannot parse is treated as unsafe.
117
+ return _UNPARSEABLE
118
+ if not _is_private_or_reserved(addr):
119
+ continue
120
+ # Audit trail: every allowlisted private destination is logged so
121
+ # operators can review what the exception is being used for. A hostname
122
+ # entry is the broader of the two, and says so.
123
+ if hostname.lower() in exempt_hostnames:
124
+ logger.warning(
125
+ "SSRF allowlist hit: dialing private address %s for host %s (purpose=%s). That entry is a "
126
+ "hostname, so every address the name resolves to is exempt \u2014 including any it starts "
127
+ "returning later. Prefer a CIDR entry, which cannot widen.",
128
+ ip_text,
129
+ hostname,
130
+ purpose or "unspecified",
131
+ )
132
+ elif any(addr in network for network in exempt_networks):
133
+ logger.info(
134
+ "SSRF allowlist hit: dialing private address %s for host %s (purpose=%s)",
135
+ ip_text,
136
+ hostname,
137
+ purpose or "unspecified",
138
+ )
139
+ else:
140
+ return _NON_PUBLIC
141
+ return None
142
+
143
+
144
+ def host_rejection_reason(
145
+ host: str | None,
146
+ *,
147
+ allowed_hosts: Sequence[str] = (),
148
+ purpose: str | None = None,
149
+ ) -> str | None:
150
+ """Return why an operator-configured ``host[:port]`` must not be dialed, or None.
151
+
152
+ Returns a reason rather than raising, because callers handed a host from
153
+ configuration must *degrade* (disable the feature) rather than fail.
154
+
155
+ The value must be a plain ``host[:port]``. One carrying a scheme, path, or
156
+ credentials would otherwise be interpolated into a URL by the caller and
157
+ silently redirect the request elsewhere — ``"evil.example.com/x"`` becomes
158
+ ``"https://evil.example.com/x/reverse"``, whose hostname check passes.
159
+
160
+ **This is a time-of-check guard, and callers use it once.** The geocoding
161
+ backend validates its host while the composition root assembles the platform,
162
+ then dials the resulting URL for the life of the process — so a name that
163
+ resolves publicly at startup and privately afterwards is not caught. What is
164
+ caught is the case that matters for an operator-supplied setting: a host that
165
+ was already internal when it was configured. Refusing redirects (which the
166
+ geocoding backend does) closes the other easy pivot. Full TOCTOU safety would
167
+ mean pinning the resolved public IP and dialing it with the original Host
168
+ header on every request.
169
+
170
+ Resolution is also unbounded: :func:`socket.getaddrinfo` takes no timeout, so
171
+ an unresponsive resolver stalls whoever called this. At startup that shows up
172
+ as a slow boot rather than a hung request.
173
+
174
+ Args:
175
+ host: The configured host authority, or ``None``.
176
+ allowed_hosts: Hostnames and CIDRs exempt from the address denylist, for
177
+ reaching a self-hosted service on a private network. An entry that is
178
+ a *hostname* exempts every address it resolves to — including a cloud
179
+ metadata endpoint — so prefer a CIDR where you can; a hostname
180
+ exemption is logged at ``WARNING`` each time it is taken.
181
+ purpose: Optional short tag identifying the outbound call, used only for
182
+ audit logging.
183
+
184
+ Returns:
185
+ A short human-readable reason, or ``None`` when the host may be dialed.
186
+ """
187
+ if host is None:
188
+ return _NOT_AN_AUTHORITY
189
+
190
+ hostname, separator, port = host.rpartition(":")
191
+ if not separator:
192
+ hostname = host
193
+ elif not (port.isdigit() and len(port) <= 5):
194
+ return _NOT_AN_AUTHORITY
195
+
196
+ if not _is_valid_hostname(hostname):
197
+ return _NOT_AN_AUTHORITY
198
+
199
+ return _address_rejection_reason(hostname, allowed_hosts=allowed_hosts, purpose=purpose)
@@ -0,0 +1,30 @@
1
+ """Guard for features behind an optional extra.
2
+
3
+ Turns a missing optional dependency into one clear message naming the extra to
4
+ install, instead of a bare ``ModuleNotFoundError`` from somewhere deep in the
5
+ call stack.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ __all__ = ["require"]
11
+
12
+
13
+ def require(module: Any, *, package: str, extra: str, feature: str) -> Any:
14
+ """Return ``module``, or raise explaining which extra provides it.
15
+
16
+ Args:
17
+ module: The lazily imported module, or ``None`` when the import failed.
18
+ package: Distribution name, e.g. ``alembic``.
19
+ extra: The JASIL extra providing it, e.g. ``migrations``.
20
+ feature: Human-readable feature name for the message.
21
+
22
+ Raises:
23
+ ImportError: When ``module`` is ``None``.
24
+ """
25
+ if module is None:
26
+ raise ImportError(
27
+ f"{feature} requires the '{package}' package, which is not installed. "
28
+ f"Install it with: pip install 'jasil[{extra}]'"
29
+ )
30
+ return module
jasil/_core/pruning.py ADDED
@@ -0,0 +1,58 @@
1
+ """Bounded batch deletes shared by the substrate's prunable tables.
2
+
3
+ Every table :mod:`jasil.retention` prunes (``event_log``, relayed
4
+ ``event_outbox`` rows, ``completed`` ``processing_jobs``) is deleted the
5
+ same way, and only the model and the filter differ. The batching is what
6
+ keeps each delete transaction short, so a prune pass never holds locks on
7
+ a hot table long enough to block the relay or the worker.
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ from sqlalchemy import ColumnExpressionArgument, delete, select
13
+ from sqlalchemy.orm import Session
14
+
15
+ from jasil._core.dialects import supports_skip_locked
16
+
17
+ # Rows deleted per batch when pruning; bounded so each delete transaction
18
+ # stays short.
19
+ PRUNE_BATCH_SIZE = 1000
20
+ # Safety cap on batches per pass so a pathological backlog cannot spin
21
+ # forever; the next scheduled pass continues where this one left off.
22
+ PRUNE_MAX_BATCHES = 10_000
23
+
24
+
25
+ def bounded_delete(
26
+ model: type[Any],
27
+ *conditions: ColumnExpressionArgument[bool],
28
+ db: Session,
29
+ batch_size: int = PRUNE_BATCH_SIZE,
30
+ ) -> int:
31
+ """
32
+ Delete the rows matching ``conditions`` in bounded, committed batches.
33
+
34
+ Args:
35
+ model: Mapped class to delete from; must expose an ``id`` column.
36
+ *conditions: Filters selecting the rows that are safe to prune.
37
+ db: Active database session.
38
+ batch_size: Maximum rows deleted per batch.
39
+
40
+ Returns:
41
+ The total number of rows deleted.
42
+ """
43
+ total = 0
44
+ for _ in range(PRUNE_MAX_BATCHES):
45
+ id_stmt = select(model.id).where(*conditions).limit(batch_size)
46
+ if supports_skip_locked(db.bind): # pragma: no cover - server-side locking, not exercised on SQLite
47
+ # Concurrent prunes step over each other's claimed page rather
48
+ # than blocking on it.
49
+ id_stmt = id_stmt.with_for_update(skip_locked=True)
50
+ ids = list(db.execute(id_stmt).scalars().all())
51
+ if not ids:
52
+ break
53
+ db.execute(delete(model).where(model.id.in_(ids)))
54
+ db.commit()
55
+ total += len(ids)
56
+ if len(ids) < batch_size:
57
+ break
58
+ return total