Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy PYTHONUNBUFFERED=1
WORKDIR /app

# Copy project metadata + every app package (flat top-level layout).
#
# This list is explicit, so a NEW APP MUST BE ADDED HERE as well as to
# INSTALLED_APPS and the wheel `packages` list — three places, none of which
# fail at test time. tests/test_app_package_names.py asserts all three agree.
COPY pyproject.toml uv.lock manage.py ./
COPY config/ ./config/
COPY jawafdehi_shared/ ./jawafdehi_shared/
Expand All @@ -42,6 +46,7 @@ COPY review/ ./review/
COPY case_proposals/ ./case_proposals/
COPY newsletter/ ./newsletter/
COPY jobs/ ./jobs/
COPY case_events/ ./case_events/
COPY llm/ ./llm/
COPY search/ ./search/
COPY discovery/ ./discovery/
Expand Down
17 changes: 17 additions & 0 deletions case_events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SPDX-License-Identifier: Hippocratic-3.0
"""The case-enrichment event bus (NATS + JetStream).

Producers publish observed facts to ``jaw.signal.>``; consumers turn them into
*proposals*; a caseworker approves. Automation never writes a case directly, and
the bus is transport — **not** a system of record. Everything durable lives in
the case record and the proposal store, which is what makes a single-replica
pilot broker an acceptable trade.

The one invariant worth stating up front: **publishing is best-effort and must
never fail a write.** A broker outage degrades enrichment; it does not degrade
the archive. See :mod:`case_events.bus`.

Nothing here is imported at Django startup, and with ``NATS_URL`` unset every
publish is a logged no-op — so the monolith runs unchanged with no broker at
all, and dev/CI need none.
"""
14 changes: 14 additions & 0 deletions case_events/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-License-Identifier: Hippocratic-3.0
from django.apps import AppConfig


class EventsConfig(AppConfig):
"""The case-enrichment event bus.

Registered as an app for management-command discovery (the consumer runner
lands here). It has no models and therefore no migrations, and it opens no
connection at startup — the bus connects lazily on first publish.
"""

default_auto_field = "django.db.models.BigAutoField"
name = "case_events"
240 changes: 240 additions & 0 deletions case_events/bus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# SPDX-License-Identifier: Hippocratic-3.0
"""Publishing to the bus from synchronous Django code.

``nats-py`` is asyncio-only; Django's request path is not. The bridge is one
background thread per process running an event loop, holding one long-lived
connection, started lazily on first publish. Publishes are handed to that loop
with ``run_coroutine_threadsafe``.

Three rules this module exists to enforce:

**Publishing is best-effort and never raises.** Every entry point swallows
its exceptions and logs. A broker outage must degrade enrichment, never a case
write — the bus is transport, the case record is the truth. If you find yourself
wanting to propagate an error from here, the thing you actually want is a job.

**One connection per process, not one per publish.** Connecting per call would
turn a burst of approvals into a burst of TCP handshakes, and this cluster has
already had an incident where a per-operation connection pattern exhausted a
server's connection cap. The loop thread and its connection are reused.

**A dead broker must not slow the request path.** The first publish after a
connection failure does not retry immediately; it fails fast for
:data:`CONNECT_RETRY_SECONDS` before trying again. Publishes are fire-and-forget
by default, so nothing in a web request waits on the network.

With ``settings.NATS_URL`` unset every publish is a logged no-op and no thread
is ever started, which is what lets this ship before the broker exists.
"""

from __future__ import annotations

import asyncio
import json
import os
import threading
import time
from typing import Any

import structlog
from django.conf import settings

logger = structlog.get_logger(__name__)

#: After a failed connect, fail fast for this long before trying again. Without
#: it, every request on a broker outage pays a full connect timeout.
CONNECT_RETRY_SECONDS = 30

#: Ceiling on how long the lazy startup may block the calling thread. Deliberately
#: short: this runs inside a web request the first time.
STARTUP_TIMEOUT_SECONDS = 5

#: Ceiling for a publish when the caller opts into waiting (``wait=True``).
PUBLISH_TIMEOUT_SECONDS = 5


def enabled() -> bool:
"""True when a broker is configured. Everything here no-ops when False."""
return bool(getattr(settings, "NATS_URL", ""))


class _Bus:
"""Owns the loop thread and the connection. One instance per process."""

def __init__(self):
self._lock = threading.Lock()
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._nc = None
self._js = None
self._pid: int | None = None
self._last_failure_at: float = 0.0

# ── lifecycle ────────────────────────────────────────────────────────────

def _reset_locked(self):
self._loop = None
self._thread = None
self._nc = None
self._js = None
self._pid = None

def _ensure_started(self) -> bool:
"""Start the loop thread and connect if needed. False if unavailable."""
with self._lock:
# A forked child inherits this object's state but NOT the parent's
# threads, so the loop it points at is gone. Under gunicorn the fork
# happens before any request, so in practice we start fresh in the
# child — but only because we check.
if self._pid is not None and self._pid != os.getpid():
logger.info("case_events.bus_reset_after_fork", inherited_pid=self._pid)
self._reset_locked()

if self._js is not None:
return True

if time.monotonic() - self._last_failure_at < CONNECT_RETRY_SECONDS:
return False

try:
self._start_locked()
return True
except Exception as exc: # noqa: BLE001 - never propagate to a write path
self._last_failure_at = time.monotonic()
self._reset_locked()
logger.warning("case_events.connect_failed", error=str(exc))
return False

def _start_locked(self):
loop = asyncio.new_event_loop()
thread = threading.Thread(
target=loop.run_forever, name="events-bus", daemon=True
)
thread.start()

future = asyncio.run_coroutine_threadsafe(self._connect(), loop)
try:
self._nc, self._js = future.result(timeout=STARTUP_TIMEOUT_SECONDS)
except Exception:
loop.call_soon_threadsafe(loop.stop)
raise

self._loop = loop
self._thread = thread
self._pid = os.getpid()
logger.info("case_events.connected", url=_redact(settings.NATS_URL))

async def _connect(self):
import nats

from case_events.streams import ensure_streams

nc = await nats.connect(
settings.NATS_URL,
name="jawafdehi-platform",
connect_timeout=STARTUP_TIMEOUT_SECONDS,
# Reconnect forever rather than giving up: this process outlives any
# broker restart, and a permanently-detached publisher that still
# looks healthy is worse than one that keeps trying.
max_reconnect_attempts=-1,
)
js = nc.jetstream()
await ensure_streams(js)
return nc, js
Comment on lines +108 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
fd -a 'bus.py|.*settings.*|.*dependencies.*|.*requirements.*|pyproject.toml|poetry.lock' . | sed 's#^\./##' | head -200

echo "== git status/stat =="
git diff --stat || true

echo "== events/bus.py outline and relevant lines =="
if [ -f events/bus.py ]; then
  wc -l events/bus.py
  sed -n '1,220p' events/bus.py | cat -n
fi

echo "== settings and connect timeout constants =="
rg -n "STARTUP_TIMEOUT_SECONDS|CONNECT_RETRY_SECONDS|NATS_URL|ensure_streams|_start_locked|events\.connect" -S .

Repository: Jawafdehi/JawafdehiAPI

Length of output: 13451


🌐 Web query:

nats-py connect_timeout parameter source nats.connect connect_timeout

💡 Result:

In the nats-py library, the connect_timeout parameter is used within the nats.connect method to specify the maximum amount of time (in seconds) allowed to establish a connection to a NATS server [1][2][3]. Key details include: - Functionality: If multiple server URLs are provided, this timeout applies individually to the connection attempt for each server in the cluster [1][4]. - Default Value: The default value for connect_timeout is 2 seconds [2][3][5]. - Usage Example: import asyncio import nats async def main: nc = await nats.connect( servers=["nats://demo.nats.io:4222"], connect_timeout=10 # Set timeout to 10 seconds) await nc.close if name == 'main': asyncio.run(main) Note that while nats-py continues to support connect_timeout in its standard client, users should be aware of library migration paths (such as changes in newer core implementations) where parameter names can occasionally be updated or deprecated [6]. Always check the documentation for your specific version if you encounter unexpected behavior [2][7].

Citations:


🌐 Web query:

asyncio.run_coroutine_threadsafe timeout result future does not cancel task exception behavior

💡 Result:

When you call asyncio.run_coroutine_threadsafe(coro, loop), it returns a concurrent.futures.Future object that acts as a bridge to the asyncio Task running in the event loop [1][2]. If you wait for this future using future.result(timeout=...) and it times out, the following behavior applies: 1. Cancellation is not automatic: The TimeoutError raised by future.result only indicates that the thread waiting for the result has timed out [1][3]. It does not automatically cancel the underlying asyncio Task [4]. 2. Manual cancellation: You must explicitly call future.cancel if you wish to stop the execution of the coroutine [1][5]. 3. Cancellation propagation: Calling future.cancel is thread-safe and will schedule a cancellation of the underlying asyncio Task [6][7]. However, because this is an asynchronous operation, the task is not guaranteed to stop immediately [6]. The Task will receive a CancelledError exception during its next cycle in the event loop [3][6]. 4. Known edge cases: There is a known limitation where simply cancelling the future may not guarantee that the underlying task has fully finished its cleanup or reached a terminal state by the time your thread proceeds [4][8]. Because the cancellation is scheduled via the event loop, there is no built-in synchronous way to wait for the task to be fully cleaned up without potential race conditions or leaving the task running temporarily [4]. In summary, future.result(timeout=...) only times out the blocking call in your external thread; you must invoke future.cancel to attempt to stop the coroutine [1][9]. Be aware that the task may continue to run briefly while the cancellation propagates [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== events/streams.py relevant lines =="
wc -l events/streams.py
sed -n '1,140p' events/streams.py | cat -n

echo "== dependency version constraints =="
sed -n '1,90p' pyproject.toml | cat -n
if [ -f poetry.lock ]; then
  rg -n 'name = "nats"|nats\.py|nats' poetry.lock pyproject.toml -S | head -80
fi

echo "== read-only behavioral model of timeout semantics from source comments =="
python3 - <<'PY'
import asyncio, concurrent, textwrap
print("concurrent.futures timeout docs excerpt:")
print("  TimeoutError from Future.result(timeout=...) indicates wait timed out; underlying Future/Task is not cancelled automatically.")
PY

Repository: Jawafdehi/JawafdehiAPI

Length of output: 9448


🌐 Web query:

nats-python v2 connect_timeout raises TimeoutError within client source

💡 Result:

In the NATS Python client (nats-py), the connect_timeout parameter is used to limit the time the client spends attempting to establish the initial connection to a server [1][2]. If the connection cannot be established within this duration, the client generally raises an exception related to connection failure [1][3]. It is important to note that nats.errors.TimeoutError exists within the library's error hierarchy [4][5] and is specifically used in contexts such as request/reply timeouts or JetStream operations [4][6][7]. While the connect_timeout logic is designed to safeguard against network issues, unresponsive servers, or slow handshakes [1], users often find that the client attempts to reconnect based on its internal allow_reconnect and max_reconnect_attempts settings [1][8]. If you wish to force the client to fail strictly after a single attempt when a timeout is reached, you should configure max_reconnect_attempts=1 [1]. Note that there is ongoing evolution in the NATS Python ecosystem; recent migration documentation for core components indicates that some parameter names have been updated (e.g., connect_timeout may be renamed to timeout in newer versions or specific sub-libraries) [9]. You should check your specific version's documentation or source code if you observe different behavior [8][9]. For definitive handling of connection exceptions, you should catch Exception or specific transport errors (like OSError or asyncio.TimeoutError) during the await nats.connect() call [1][3].

Citations:


🌐 Web query:

nats-py 2.9.3 aio/client.py connect_timeout _process_op

💡 Result:

In the nats-py library (the legacy asyncio NATS client), connect_timeout and _process_op are related to connection management and error handling within nats/aio/client.py [1][2]. connect_timeout: This is a configuration option used during the connection process [1][3]. It defines the maximum duration (in seconds) the client will wait for the transport layer (TCP connection establishment, TLS handshake, and initial NATS protocol handshake) to complete successfully [4][5]. It is not an absolute timeout for the entire connection lifecycle, but specifically for establishing the initial connection to a NATS server [4]. If the connection cannot be established within this time, the attempt is aborted [4]. _process_op_err: While your query referenced _process_op, the relevant method in nats/aio/client.py is typically _process_op_err [1][6]. This internal method is responsible for processing errors that occur while reading or parsing the NATS protocol [1][6]. When a transport error, protocol error, or unexpected end-of-file (EOF) occurs, the client's read loop calls _process_op_err [6]. This method manages the cleanup and, if allowed by the configuration (e.g., allow_reconnect=True), triggers the reconnection logic to attempt a connection to another server in the pool [1][6]. Key points: - The connect_timeout parameter is configurable during the nats.connect call [5]. - _process_op_err is an internal error-handling mechanism invoked during read loop failures [1][6]. - If you encounter issues with connections hanging, verify that your connect_timeout is set appropriately for your network conditions and that you have registered an error_cb to handle and log connectivity issues [4][7].

Citations:


Separate the NATS connection timeout from the startup+stream assertion timeout.

_connect() spends the first 5 seconds on nats.connect(), then must also wait on three sequential js.add_stream() calls, but the wrapping future.result(timeout=STARTUP_TIMEOUT_SECONDS) can time out after connection succeeds. A timeout during stream assertion closes the loop and logs events.connect_failed, leaving the still-running _connect() connection alive because the future timeout does not cancel the task. Use headroom for ensure_streams() / an explicit cancellation path that closes the connection if the coroutine later completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@events/bus.py` around lines 108 - 143, Separate the NATS connection timeout
from the overall startup timeout in _start_locked and _connect: allow
ensure_streams to complete after nats.connect without the outer future expiring
prematurely. If startup still times out or fails, cancel the submitted _connect
coroutine and ensure any connection it creates is closed when cancellation or
late completion occurs, preventing a detached NATS connection from remaining
alive.


def close(self):
"""Drain and disconnect. For tests and orderly shutdown."""
with self._lock:
loop, nc = self._loop, self._nc
self._reset_locked()
if loop is None:
return
try:
if nc is not None:
asyncio.run_coroutine_threadsafe(nc.drain(), loop).result(timeout=5)
except Exception as exc: # noqa: BLE001 - shutdown is best-effort too
logger.warning("case_events.close_failed", error=str(exc))
finally:
loop.call_soon_threadsafe(loop.stop)

# ── publishing ───────────────────────────────────────────────────────────

def publish(self, subject: str, envelope: dict[str, Any], wait: bool = False) -> bool:
"""Publish one envelope. Returns True if it was handed to the loop.

A True return means accepted for delivery, not delivered — with
``wait=False`` the JetStream ack arrives after this returns, and a
failure then surfaces in the logs via the done-callback.
"""
if not self._ensure_started():
return False

body = json.dumps(envelope, ensure_ascii=False, default=str).encode("utf-8")
# Nats-Msg-Id is what makes JetStream collapse a duplicate publish inside
# its dedup window — the same idempotency spine as the proposal's
# dedup_key. Omitted rather than sent empty when a producer has no key.
headers = {}
if envelope.get("dedup_key"):
headers["Nats-Msg-Id"] = envelope["dedup_key"]

try:
future = asyncio.run_coroutine_threadsafe(
self._js.publish(subject, body, headers=headers or None), self._loop
)
except Exception as exc: # noqa: BLE001 - loop may have died under us
logger.warning("case_events.publish_failed", subject=subject, error=str(exc))
return False

if wait:
try:
future.result(timeout=PUBLISH_TIMEOUT_SECONDS)
except Exception as exc: # noqa: BLE001
logger.warning("case_events.publish_failed", subject=subject, error=str(exc))
return False
else:
future.add_done_callback(lambda f: _log_result(f, subject))
return True


def _log_result(future, subject: str):
"""Surface a fire-and-forget failure. Nothing else observes these."""
try:
future.result()
except Exception as exc: # noqa: BLE001
logger.warning("case_events.publish_failed", subject=subject, error=str(exc))


def _redact(url: str) -> str:
"""Strip credentials from a nats:// URL before logging it."""
if not url or "@" not in url:
return url
scheme, _, rest = url.partition("://")
return f"{scheme}://***@{rest.rpartition('@')[2]}" if scheme else url


_bus = _Bus()


def publish(subject: str, envelope: dict[str, Any], wait: bool = False) -> bool:
"""Best-effort publish. Never raises; returns False when nothing was sent.

Args:
subject: See :mod:`case_events.subjects`.
envelope: Built by :func:`case_events.envelope.build_envelope`.
wait: Block for the JetStream ack (up to
:data:`PUBLISH_TIMEOUT_SECONDS`). Leave False in a request path;
useful in management commands and tests where you want the result.
"""
if not enabled():
logger.debug("case_events.publish_skipped", subject=subject, reason="NATS_URL unset")
return False
try:
return _bus.publish(subject, envelope, wait=wait)
except Exception as exc: # noqa: BLE001 - the whole point of this module
logger.warning("case_events.publish_failed", subject=subject, error=str(exc))
return False


def close():
"""Drain and disconnect the process-wide connection."""
_bus.close()
93 changes: 93 additions & 0 deletions case_events/envelope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# SPDX-License-Identifier: Hippocratic-3.0
"""The message envelope every event on the bus carries.

One shape for signals and case events alike, so a consumer can read provenance,
identity and timing without knowing the subject. The type-specific part is
confined to ``payload``.

Two fields carry more weight than they look:

``subject_refs`` are stable ``@id`` IRIs (case, court-case, NES entity,
material) and are **the join key** between a message and our records. They must
be built with :mod:`jawafdehi_shared.entities.ids`, never formatted by hand:
``build_courtcase_iri`` lowercases both segments, so a hand-rolled
``.../courtcase/special/082-CR-0154`` matches nothing.

``dedup_key`` is sent as the ``Nats-Msg-Id`` header, which is what makes
JetStream drop a duplicate publish inside its dedup window. It is the same
idempotency spine ``CaseUpdateProposal.dedup_key`` uses, and producers must
construct it deterministically from the fact — not from a timestamp or a random
id, or it defeats itself.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any


def utcnow() -> datetime:
"""Timezone-aware UTC now. Seam for tests to freeze."""
return datetime.now(timezone.utc)


def _iso(value: datetime) -> str:
"""RFC 3339 / ISO 8601 in UTC, always with a ``Z`` suffix.

``datetime.isoformat()`` renders UTC as ``+00:00``; normalising to ``Z``
keeps the wire format stable for non-Python consumers.
"""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")


def build_envelope(
*,
subject: str,
payload: dict[str, Any],
producer: str,
subject_refs: list[str] | None = None,
dedup_key: str = "",
source: str = "",
raw_ref: str = "",
occurred_at: datetime | None = None,
) -> dict[str, Any]:
"""Assemble one bus message.

Args:
subject: The subject it will be published on (see :mod:`case_events.subjects`).
Carried in the body as well as the NATS subject so a message stays
self-describing once it has been archived, DLQ'd, or re-published
under a different subject.
payload: Type-specific body. Must be JSON-serialisable.
producer: What emitted this — ``"platform"`` for the monolith,
``"consumer:<name>"`` for a consumer, ``"producer:<name>"`` for a
scraper.
subject_refs: Stable ``@id`` IRIs this message is about.
dedup_key: Deterministic idempotency key; becomes ``Nats-Msg-Id``.
source: Where the underlying fact came from (URL, ``@id``, or a
well-known token like ``"caseworker"``).
raw_ref: Pointer to the raw artefact behind the fact, when there is one.
occurred_at: When the fact happened. Defaults to now, but should be
passed whenever the real time is known — for a scraped hearing that
is the docket date, not the moment we noticed it.

Returns:
A JSON-serialisable dict.
"""
now = utcnow()
return {
"subject": subject,
"producer": producer,
"subject_refs": list(subject_refs or []),
"dedup_key": dedup_key,
"source": source,
"raw_ref": raw_ref,
# occurred_at is when the FACT happened; published_at is when we emitted
# it. They differ by however long the producer took to notice, which is
# the number you need when auditing lag.
"occurred_at": _iso(occurred_at or now),
"published_at": _iso(now),
"payload": payload,
}
Loading
Loading