Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ What counts as a breaking change — and what does not — is defined in [API st

## [Unreleased]

### Added

- **`jasil.providers.MAIL_SEND_DEADLINE_SECONDS`** is public: the upper bound on one SMTP `send` (90 seconds, DNS lookups excepted). Import it to size a lease or timeout around a send; the value may change in a minor release. `jasil.container.SMTP_SEND_DEADLINE_SECONDS`, which was not covered, is removed. ([#57](https://github.com/endurain-project/jasil/issues/57))

## [0.17.0] - 2026-11-04

This release fixes the first audit write on a fresh SQLite database failing with `database is locked`. Migration `rev0010` creates the `jasil_instance` row, and `ensure_instance_id` is now public.
Expand Down
6 changes: 3 additions & 3 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ modules:

| Module | Surface |
|---|---|
| `jasil.providers` | The capability protocols and their method signatures, the value types they exchange (`GeocodedPlace`, `MailMessage`, `UploadSession`, ...), `AuditSink` and `AuditSinkConflictError`, and `MAX_REALTIME_KEY_LENGTH`. |
| `jasil.providers` | The capability protocols and their method signatures, the value types they exchange (`GeocodedPlace`, `MailMessage`, `UploadSession`, ...), `AuditSink` and `AuditSinkConflictError`, `MAX_REALTIME_KEY_LENGTH`, and `MAIL_SEND_DEADLINE_SECONDS` (its value may change in a minor release). |
| `jasil.events` | `Event`, `new_event`, `META_REQUEST_ID`, `INITIAL_SCHEMA_VERSION`, `MAX_EVENT_ID_LENGTH`, `MAX_EVENT_TYPE_LENGTH`, `MAX_SOURCE_LENGTH`. |
| `jasil.event_versioning` | `VersionedPayload`, `parse_payload`, `UnsupportedEventVersionError`. |
| `jasil.profile` | `DeploymentProfile`, `JobWorkerMode`, `parse_profile`, `parse_job_worker_mode`, `classify_state_uri` with its `StateBackendKind`, `DeploymentTopology` (`multi_process`, `multi_node`, `multi_process_causes`) and `resolve_topology`. |
Expand Down Expand Up @@ -142,8 +142,8 @@ wiring and tests:
- The limit constants and helpers a public module imports from `jasil._core`:
`MAX_AUDIT_*`, `MAX_JOB_BATCH_SIZE`, `MAX_PARTITION_DAYS_AHEAD` and
`MAX_RETENTION_DAYS` in `jasil.settings`, `MAX_DASHBOARD_*` and `check_limit` in
`jasil.admin`, `MAX_KEY_LENGTH` in `jasil.providers`, and `SMTP_PORTS`,
`SMTP_SEND_DEADLINE_SECONDS` and `resolve_reverse_endpoint` in `jasil.container`.
`jasil.admin`, `MAX_KEY_LENGTH` in `jasil.providers`, and `SMTP_PORTS` and
`resolve_reverse_endpoint` in `jasil.container`.
Their values are described in the configuration page; the names are not covered.
- `jasil.testing.DEFAULT_NOW`, `jasil.jobs.registry.normalize_queue_selector` and
`JobHandler`, and the `profile`, `web_workers` and `job_worker_mode` fields of
Expand Down
4 changes: 2 additions & 2 deletions docs/durable-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,8 +561,8 @@ failure.
- **Delivery is at-least-once.** A retry after a lost acknowledgement sends the
message twice.
- **Size `jobs.max_runtime_seconds` for the whole handler, not one send.** One
SMTP send can take up to 90 seconds, and the DNS lookups before it are not
counted against that deadline. A handler that sends N messages can take N times
SMTP send can take up to `jasil.providers.MAIL_SEND_DEADLINE_SECONDS` (90
seconds), and the DNS lookups before it are not counted against that deadline. A handler that sends N messages can take N times
that. The worker's heartbeat keeps the job's lease alive while it runs, so the
lease itself no longer needs to be that long; the cap is
`max_runtime_seconds` (default 30 minutes). Past it the job is abandoned and a
Expand Down
4 changes: 4 additions & 0 deletions docs/providers-and-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,10 @@ CR/LF in a header value). `MemoryMail` validates through the same builder as
`MailError` messages carry a failure category and SMTP status code only — never
an address, subject, body or server text.

One `SmtpMail` send is bounded by `MAIL_SEND_DEADLINE_SECONDS` (90 seconds; DNS
lookups are not counted). Import it to size a lease or timeout around a send
rather than copying the value, which may change in a minor release.

The sender is `from_address`, independent of the SMTP `username`: relays such as
Brevo log you in as a generated identity and reject a `From` that is not a
verified sender. The `From` header keeps any display name; the envelope sender is
Expand Down
28 changes: 13 additions & 15 deletions jasil/backends/mail_smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,22 @@
import jasil._core.network as network
from jasil._core.job_context import lease_lost
from jasil._core.mail_message import build_mime, parse_address
from jasil.providers import ClockProvider, MailError, MailMessage
from jasil.providers import MAIL_SEND_DEADLINE_SECONDS, ClockProvider, MailError, MailMessage

logger = logging.getLogger(__name__)

# Applied to every socket operation of a send (seconds).
_TIMEOUT_SECONDS = 30
# Ceiling on one ``send`` (public: the composition root compares the job lease to
# it). It bounds one message, not one job: a handler sending N messages can take N
# times this, plus the DNS lookups it cannot interrupt, and its lease must cover
# all of that or the reaper requeues it mid-delivery. A socket timeout alone
# cannot enforce it: every read restarts the timeout, so a server dripping its
# reply a byte at a time never trips one. A watchdog shuts the socket down when it expires, and
# keeps doing so until the send ends: the socket may not exist yet at the
# deadline (a slow connect), and a single shutdown would then be missed. The
# watchdog holds a duplicate of the raw socket, because during a TLS handshake
# ``connection.sock`` is the pre-wrap socket, which the wrap has already detached.
# A DNS lookup is the one step it cannot interrupt.
SEND_DEADLINE_SECONDS = 90
# ``MAIL_SEND_DEADLINE_SECONDS`` is the ceiling on one ``send``. It bounds one
# message, not one job: a handler sending N messages can take N times this, plus
# the DNS lookups it cannot interrupt. A socket timeout alone cannot enforce it:
# every read restarts the timeout, so a server dripping its reply a byte at a time
# never trips one. A watchdog shuts the socket down when it expires, and keeps
# doing so until the send ends: the socket may not exist yet at the deadline (a
# slow connect), and a single shutdown would then be missed. The watchdog holds a
# duplicate of the raw socket, because during a TLS handshake ``connection.sock``
# is the pre-wrap socket, which the wrap has already detached. A DNS lookup is the
# one step it cannot interrupt.
_WATCH_INTERVAL_SECONDS = 0.1
_DEADLINE_EXCEEDED = "SMTP send exceeded its overall deadline"

Expand Down Expand Up @@ -244,7 +242,7 @@ def _arm(connection: smtplib.SMTP, deadline: float) -> None:
@staticmethod
def _watch(connection: smtplib.SMTP, expired: threading.Event, finished: threading.Event) -> None:
"""Watchdog: at the deadline, flag it and keep unblocking reads until done."""
if finished.wait(SEND_DEADLINE_SECONDS):
if finished.wait(MAIL_SEND_DEADLINE_SECONDS):
return
expired.set()
while not finished.is_set():
Expand Down Expand Up @@ -307,7 +305,7 @@ def _connect(self, connection: smtplib.SMTP, deadline: float) -> tuple[int, byte
def _deliver(self, mime: EmailMessage) -> dict[str, tuple[int, bytes]]:
"""Connect, secure, authenticate and send; returns refused recipients."""
context = self._context
deadline = time.monotonic() + SEND_DEADLINE_SECONDS
deadline = time.monotonic() + MAIL_SEND_DEADLINE_SECONDS
# Built without a host so nothing is dialed before the watchdog is armed:
# the greeting is a read like any other, and can be dripped as slowly.
connection: smtplib.SMTP
Expand Down
6 changes: 3 additions & 3 deletions jasil/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
from jasil.backends.lock_noop import NoopLock
from jasil.backends.lock_pg import PgAdvisoryLock
from jasil.backends.mail_memory import MemoryMail, NullMail
from jasil.backends.mail_smtp import SEND_DEADLINE_SECONDS as SMTP_SEND_DEADLINE_SECONDS
from jasil.backends.mail_smtp import SmtpMail
from jasil.backends.realtime_inprocess import InProcessRealtime
from jasil.backends.realtime_none import NullRealtime
Expand All @@ -49,6 +48,7 @@
from jasil.backends.storage_none import NullStorage
from jasil.profile import DeploymentProfile
from jasil.providers import (
MAIL_SEND_DEADLINE_SECONDS,
AuditSink,
ClockProvider,
EventBusProvider,
Expand Down Expand Up @@ -527,7 +527,7 @@ def _build_mail(settings: JasilSettings, clock: ClockProvider) -> MailProvider:
return MemoryMail(clock, mail.from_address)
if not mail.secure:
logger.warning("Mail over smtp:// is CLEARTEXT (mail.secure=False); use only on a trusted private network")
if settings.jobs.enabled and settings.jobs.max_runtime_seconds <= SMTP_SEND_DEADLINE_SECONDS:
if settings.jobs.enabled and settings.jobs.max_runtime_seconds <= MAIL_SEND_DEADLINE_SECONDS:
# A running job keeps its lease, but only up to ``max_runtime_seconds``: past
# it the job is abandoned and a send in flight is delivered by a worker that
# no longer owns the job. Only the floor is checkable here: a handler
Expand All @@ -536,7 +536,7 @@ def _build_mail(settings: JasilSettings, clock: ClockProvider) -> MailProvider:
"jobs.max_runtime_seconds (%d) is not above the SMTP send deadline (%d s, per "
"message); a slow send can be abandoned mid-delivery and sent again by a retry",
settings.jobs.max_runtime_seconds,
SMTP_SEND_DEADLINE_SECONDS,
MAIL_SEND_DEADLINE_SECONDS,
)
resolved_port = parts.port or SMTP_PORTS[scheme]
logger.info("Mail enabled via %s%s", scheme, " (authenticated)" if mail.username else "")
Expand Down
5 changes: 5 additions & 0 deletions jasil/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
#: (code points, not bytes).
MAX_REALTIME_KEY_LENGTH = MAX_KEY_LENGTH

#: Upper bound on one :meth:`MailProvider.send` through the built-in SMTP backend, in seconds. DNS lookups are not
#: counted against it, so a host sizing a lease or timeout around a send adds a margin. Import it rather than copy
#: the value, which may change in a minor release.
MAIL_SEND_DEADLINE_SECONDS = 90


class StateBackendUnavailableError(RuntimeError):
"""Raised by a :class:`StateProvider` when its backing store is unreachable.
Expand Down
5 changes: 3 additions & 2 deletions tests/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from jasil.backends.storage_none import NullStorage
from jasil.backends.storage_s3 import S3Storage
from jasil.profile import DeploymentProfile, JobWorkerMode
from jasil.providers import MAIL_SEND_DEADLINE_SECONDS

#: Taken before the autouse fixture replaces it, for the tests of client ownership.
REAL_GET_SHARED_CLIENT = redis_clients.get_shared_client
Expand Down Expand Up @@ -737,7 +738,7 @@ def _build(**mail):

def test_a_runtime_cap_not_above_the_smtp_deadline_warns(self, caplog):
"""A running job keeps its lease only up to ``max_runtime_seconds``."""
jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=60)
jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=MAIL_SEND_DEADLINE_SECONDS)
cfg = _settings(
mail=settings.MailSettings(uri="smtps://mail.example.com", from_address="a@example.com"), jobs=jobs
)
Expand All @@ -747,7 +748,7 @@ def test_a_runtime_cap_not_above_the_smtp_deadline_warns(self, caplog):
assert "max_runtime_seconds" in caplog.text

def test_a_longer_runtime_cap_does_not_warn(self, caplog):
jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=1800)
jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=MAIL_SEND_DEADLINE_SECONDS + 1)
cfg = _settings(
mail=settings.MailSettings(uri="smtps://mail.example.com", from_address="a@example.com"), jobs=jobs
)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from jasil._core.mail_message import build_mime
from jasil.backends.mail_memory import MemoryMail, NullMail
from jasil.backends.mail_smtp import SmtpMail
from jasil.providers import MailError, MailMessage, MailNotConfiguredError, MailProvider
from jasil.providers import MAIL_SEND_DEADLINE_SECONDS, MailError, MailMessage, MailNotConfiguredError, MailProvider

# Windows: shutdown() does not wake a thread blocked in recv(), so the watchdog cannot interrupt a read there; only the per-read timeout bounds one.
CANNOT_INTERRUPT_A_READ = pytest.mark.skipif(
Expand Down Expand Up @@ -534,7 +534,7 @@ def test_cleartext_does_not_dial_another_address_after_the_deadline(self, monkey

def connect(self, host, port):
tried.append(host)
clock[0] += mail_smtp.SEND_DEADLINE_SECONDS + 1 # a failure that took longer than the whole send may
clock[0] += MAIL_SEND_DEADLINE_SECONDS + 1 # a failure that took longer than the whole send may
raise OSError("slow failure")

monkeypatch.setattr(FakeSMTP, "connect", connect)
Expand Down Expand Up @@ -815,7 +815,7 @@ def _start(connection):
def test_it_releases_a_read_through_the_duplicate_it_watches(self, monkeypatch, blocked_read):
"""``watched`` is a duplicate of the raw socket; shutting it down reaches a TLS-wrapped read too."""
sock, released = blocked_read
monkeypatch.setattr(mail_smtp, "SEND_DEADLINE_SECONDS", 0)
monkeypatch.setattr(mail_smtp, "MAIL_SEND_DEADLINE_SECONDS", 0)
connection = SimpleNamespace(watched=sock.dup(), sock=None)
expired, finished, watchdog = self._start(connection)
try:
Expand All @@ -830,7 +830,7 @@ def test_it_releases_a_read_through_the_duplicate_it_watches(self, monkeypatch,
def test_a_socket_that_appears_after_the_deadline_is_shut_down_too(self, monkeypatch, blocked_read):
"""A slow connect hands the socket over only after the deadline; one shutdown attempt would miss it."""
sock, released = blocked_read
monkeypatch.setattr(mail_smtp, "SEND_DEADLINE_SECONDS", 0)
monkeypatch.setattr(mail_smtp, "MAIL_SEND_DEADLINE_SECONDS", 0)
connection = SimpleNamespace(watched=None, sock=None)
expired, finished, watchdog = self._start(connection)
try:
Expand Down
Loading