diff --git a/.env.example b/.env.example index b27551c..f535a65 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,23 @@ DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0 DJANGO_SECURE_HSTS_PRELOAD=0 DATABASE_URL=postgres://goggles:replace-with-long-random-database-password@db:5432/goggles GOGGLES_MAX_DUMP_BYTES=52428800 +GOGGLES_MAX_DUMP_RECORDS=50000 +GOGGLES_MAX_JSONL_LINE_BYTES=2097152 +GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST=50000 +GOGGLES_AGENT_EXPORT_MAX_EVENTS=50000 +GOGGLES_FILE_UPLOAD_MEMORY_BYTES=1048576 +GOGGLES_UPLOADS_ENABLED=1 +GOGGLES_EXPORTS_ENABLED=1 +GOGGLES_WEB_MEMORY_LIMIT=16g +GOGGLES_WEB_CPUS=2.0 +GOGGLES_WEB_PIDS_LIMIT=256 +GOGGLES_WEB_LOG_MAX_SIZE=20m +GOGGLES_WEB_LOG_MAX_FILES=5 +GOGGLES_WEB_WORKERS=3 +GOGGLES_WEB_THREADS=4 +GOGGLES_WEB_TIMEOUT_SECONDS=300 +GOGGLES_WEB_MAX_REQUESTS=500 +GOGGLES_WEB_MAX_REQUESTS_JITTER=50 GLITCHTIP_DSN=https://d550950965a64eb689f5e289416faa42@glitch.ipf.dev/1 GLITCHTIP_SECURITY_ENDPOINT=https://glitch.ipf.dev/api/1/security/?glitchtip_key=d550950965a64eb689f5e289416faa42 GLITCHTIP_ENVIRONMENT=production diff --git a/README.md b/README.md index d049874..2529fe4 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,23 @@ DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS=0 DJANGO_SECURE_HSTS_PRELOAD=0 DATABASE_URL=postgres://goggles:replace-with-long-random-database-password@db:5432/goggles GOGGLES_MAX_DUMP_BYTES=52428800 +GOGGLES_MAX_DUMP_RECORDS=50000 +GOGGLES_MAX_JSONL_LINE_BYTES=2097152 +GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST=50000 +GOGGLES_AGENT_EXPORT_MAX_EVENTS=50000 +GOGGLES_FILE_UPLOAD_MEMORY_BYTES=1048576 +GOGGLES_UPLOADS_ENABLED=1 +GOGGLES_EXPORTS_ENABLED=1 +GOGGLES_WEB_MEMORY_LIMIT=16g +GOGGLES_WEB_CPUS=2.0 +GOGGLES_WEB_PIDS_LIMIT=256 +GOGGLES_WEB_LOG_MAX_SIZE=20m +GOGGLES_WEB_LOG_MAX_FILES=5 +GOGGLES_WEB_WORKERS=3 +GOGGLES_WEB_THREADS=4 +GOGGLES_WEB_TIMEOUT_SECONDS=300 +GOGGLES_WEB_MAX_REQUESTS=500 +GOGGLES_WEB_MAX_REQUESTS_JITTER=50 GLITCHTIP_DSN=https://d550950965a64eb689f5e289416faa42@glitch.ipf.dev/1 GLITCHTIP_SECURITY_ENDPOINT=https://glitch.ipf.dev/api/1/security/?glitchtip_key=d550950965a64eb689f5e289416faa42 GLITCHTIP_ENVIRONMENT=production @@ -135,6 +152,18 @@ POSTGRES_USER=goggles POSTGRES_PASSWORD=replace-with-long-random-database-password ``` +Compose reads container resource and logging limits while it parses the Compose +file, before a service's `env_file` is applied. The default `.env` works for +both purposes automatically. When using a custom file, supply it through both +mechanisms so values such as `GOGGLES_WEB_MEMORY_LIMIT` are not silently left at +their defaults: + +```sh +export GOGGLES_ENV_FILE=/absolute/path/to/goggles.env +docker compose --env-file "$GOGGLES_ENV_FILE" config +docker compose --env-file "$GOGGLES_ENV_FILE" up -d --build +``` + `GLITCHTIP_DSN` enables server-side exception reporting and 5% performance tracing by default. `GLITCHTIP_SECURITY_ENDPOINT` enables report-only CSP violation reporting in browsers; that endpoint contains a public project key and @@ -285,7 +314,17 @@ docker compose exec web python manage.py shell -c "from forensics.models import - Audit logs preserve raw engine ids, group refs, message ids, digests, payload metadata, raw lines, raw uploaded text, user agents, and source IPs; protect the database and backups accordingly. - Brain disk encryption is the expected at-rest protection for v1. -- Upload size defaults to 50 MiB via `GOGGLES_MAX_DUMP_BYTES`. +- Upload size defaults to 50 MiB via `GOGGLES_MAX_DUMP_BYTES`. Processing is + additionally bounded to 50,000 non-empty records and 2 MiB per JSONL line; + multipart bodies spool to disk after 1 MiB. Over-complex uploads are retained + as one quarantined raw artifact instead of being expanded into per-line ORM + objects. +- Projection APIs default to 100 rows and cap requests at 500. Action-history + scans and synchronous agent exports have separate 50,000-event safety caps. +- The Compose web service defaults to a configurable 16 GiB no-swap cgroup + limit (`GOGGLES_WEB_MEMORY_LIMIT`) and recycles Gunicorn workers after a + jittered request budget. Keep those host-protection limits in place even if + application limits are raised. - Purge stored audit data without removing users or upload tokens with `manage.py purge_audit_data`. Run it with `--dry-run` first, then `--confirm-delete-audit-data` to perform a deployment cutover. Rebuild the diff --git a/config/settings.py b/config/settings.py index 8dab1b9..bca04f2 100644 --- a/config/settings.py +++ b/config/settings.py @@ -276,19 +276,38 @@ def scrub_glitchtip_event(event, _hint): LOGOUT_REDIRECT_URL = "login" GOGGLES_MAX_DUMP_BYTES = int(os.environ.get("GOGGLES_MAX_DUMP_BYTES", 50 * 1024 * 1024)) +GOGGLES_MAX_DUMP_RECORDS = int(os.environ.get("GOGGLES_MAX_DUMP_RECORDS", 50_000)) +GOGGLES_MAX_JSONL_LINE_BYTES = int(os.environ.get("GOGGLES_MAX_JSONL_LINE_BYTES", 2 * 1024 * 1024)) +GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST = int( + os.environ.get("GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST", 50_000) +) +GOGGLES_AGENT_EXPORT_MAX_EVENTS = int(os.environ.get("GOGGLES_AGENT_EXPORT_MAX_EVENTS", 50_000)) GOGGLES_UPLOADS_ENABLED = env_bool("GOGGLES_UPLOADS_ENABLED", True) # Operational kill-switch for the streaming group-export endpoint, mirroring the # upload toggle. Lets an operator shed a resource-intensive read surface without a # redeploy. GOGGLES_EXPORTS_ENABLED = env_bool("GOGGLES_EXPORTS_ENABLED", True) DATA_UPLOAD_MAX_MEMORY_SIZE = GOGGLES_MAX_DUMP_BYTES -FILE_UPLOAD_MAX_MEMORY_SIZE = GOGGLES_MAX_DUMP_BYTES +FILE_UPLOAD_MAX_MEMORY_SIZE = min( + GOGGLES_MAX_DUMP_BYTES, + int(os.environ.get("GOGGLES_FILE_UPLOAD_MEMORY_BYTES", 1024 * 1024)), +) +for setting_name in ( + "GOGGLES_MAX_DUMP_BYTES", + "GOGGLES_MAX_DUMP_RECORDS", + "GOGGLES_MAX_JSONL_LINE_BYTES", + "GOGGLES_MAX_ACTION_EVENTS_PER_REQUEST", + "GOGGLES_AGENT_EXPORT_MAX_EVENTS", + "FILE_UPLOAD_MAX_MEMORY_SIZE", +): + if globals()[setting_name] <= 0: + raise ImproperlyConfigured(f"{setting_name} must be a positive integer.") # The upload endpoint only ever ingests a single file part, and every part at -# or under FILE_UPLOAD_MAX_MEMORY_SIZE is buffered in RAM. Capping the number -# of files at 1 stops a multipart request from accumulating many sub-threshold -# parts (each passing the per-file size check) into multiple gigabytes of -# resident memory. MaxDumpSizeUploadHandler additionally bounds the cumulative -# bytes across parts as defense in depth. +# or under FILE_UPLOAD_MAX_MEMORY_SIZE is buffered in RAM. Keep that threshold +# much smaller than the accepted dump size so normal multipart uploads spool to +# a temporary file before ingestion. Capping the number of files at 1 stops a +# multipart request from accumulating many sub-threshold parts, while +# MaxDumpSizeUploadHandler additionally bounds cumulative bytes across parts. DATA_UPLOAD_MAX_NUMBER_FILES = 1 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/docker-compose.yml b/docker-compose.yml index 00cf924..8b87c44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,14 @@ services: build: . init: true restart: unless-stopped + # Keep a pathological request inside the Goggles cgroup instead of letting + # it exhaust host RAM and swap. Equal memory/swap limits disable additional + # swap allocation for this container. + mem_limit: ${GOGGLES_WEB_MEMORY_LIMIT:-16g} + memswap_limit: ${GOGGLES_WEB_MEMORY_LIMIT:-16g} + mem_swappiness: 0 + cpus: ${GOGGLES_WEB_CPUS:-2.0} + pids_limit: ${GOGGLES_WEB_PIDS_LIMIT:-256} env_file: ${GOGGLES_ENV_FILE:-.env} depends_on: db: @@ -45,9 +53,18 @@ services: command: > sh -c "python manage.py migrate --noinput && python manage.py collectstatic --noinput && - gunicorn config.wsgi:application --bind 0.0.0.0:8000 - --workers 3 --threads 4 --timeout 300 - --max-requests 500 --max-requests-jitter 50" + exec gunicorn config.wsgi:application + --bind 0.0.0.0:8000 + --workers $${GOGGLES_WEB_WORKERS:-3} + --threads $${GOGGLES_WEB_THREADS:-4} + --timeout $${GOGGLES_WEB_TIMEOUT_SECONDS:-300} + --max-requests $${GOGGLES_WEB_MAX_REQUESTS:-500} + --max-requests-jitter $${GOGGLES_WEB_MAX_REQUESTS_JITTER:-50}" + logging: + driver: json-file + options: + max-size: ${GOGGLES_WEB_LOG_MAX_SIZE:-20m} + max-file: ${GOGGLES_WEB_LOG_MAX_FILES:-5} healthcheck: test: [ diff --git a/docs/api-v1.md b/docs/api-v1.md index eb4e100..55c8276 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -44,15 +44,19 @@ Projection endpoints support these filters where the field applies: - `epoch` - `from_ms` - `to_ms` -- `limit`: defaults to `500`, capped at `5000` +- `limit`: defaults to `100`, capped at `500` - `offset`: defaults to `0` +Compatibility note: releases before the memory-pressure hardening defaulted to +500 rows and allowed up to 5,000. API consumers that relied on those larger +pages must now follow `has_more` and advance `offset` in batches of at most 500. + Paginated responses include: ```json { "pagination": { - "limit": 500, + "limit": 100, "offset": 0, "returned": 10, "has_more": false, @@ -68,6 +72,10 @@ Action attribution endpoints also support: - `origin`: for example `local_user`, `system`, or `observed_group_event` - `action`: the normalized human-action name, such as `send_message` +Action pagination includes `scan_truncated` and `scan_limit`. If +`scan_truncated` is true, Goggles bounded the request to the newest safe action +window; use narrower filters rather than requesting an unbounded group history. + ## Sensitivity Metadata Derived objects that may contain decrypted content, full author identifiers, raw diff --git a/docs/deployment.md b/docs/deployment.md index 4e0516c..d6ff745 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,3 +54,43 @@ Capacity model — size the database for it: The edge proxy (Caddy) streams `reverse_proxy` responses by default, so no proxy change is required; `nginx` serves only static assets and is not in the export request path. + +## Memory-pressure deployment + +The performance hardening release does **not** require purging audit data. +Deploy it with uploads paused so no old worker continues a high-memory ingest: + +Set the Compose environment source once before running the commands below. This +explicit `--env-file` is required for Compose-time resource and logging limits; +the service-level `env_file` alone only populates the container environment. + +```sh +export GOGGLES_ENV_FILE="${GOGGLES_ENV_FILE:-.env}" +``` + +1. Set `GOGGLES_UPLOADS_ENABLED=0` in the production environment. +2. Recreate the web service so the changed environment and Compose resource + limits take effect: + `docker compose --env-file "$GOGGLES_ENV_FILE" up -d --build --force-recreate web`. +3. Confirm the container has the expected 16 GiB memory/no-swap boundary (or + the value set in `GOGGLES_WEB_MEMORY_LIMIT`) by resolving the actual Compose + container rather than assuming a project-specific name: + `web_container_id="$(docker compose --env-file "$GOGGLES_ENV_FILE" ps -q web)"; test -n "$web_container_id"; docker inspect "$web_container_id"`. + Wait for the health check to pass. +4. While uploads remain paused, exercise an authenticated group overview, + delivery tab, and evidence tab while watching `docker stats`. +5. Set `GOGGLES_UPLOADS_ENABLED=1` and recreate the web service again with the + same `--env-file` command from step 2. +6. Perform a representative upload while watching `docker stats`, then recheck + the group overview, delivery tab, and evidence tab. + +Do not use `purge_audit_data` for this deployment. The query changes avoid +hydrating stored raw bodies without changing their schema or deleting evidence. + +The Compose service keeps three threaded workers by default, recycles each +after a jittered 500-request budget, and constrains the whole web container to +a configurable 16 GiB default (`GOGGLES_WEB_MEMORY_LIMIT`) with no additional +swap. CPU, PID, and Docker log rotation limits are configurable through the +adjacent `GOGGLES_WEB_*` settings. During an incident, set +`GOGGLES_WEB_WORKERS=1` before the recreate to prevent concurrent amplification; +restore the measured production worker count only after memory remains stable. diff --git a/forensics/admin.py b/forensics/admin.py index b99e23b..d6120b9 100644 --- a/forensics/admin.py +++ b/forensics/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.db.models.functions import Length, Substr from django.urls import reverse from django.utils.html import format_html from django.utils.http import urlencode @@ -97,6 +98,12 @@ class AuditFileAdmin(admin.ModelAdmin): "events_link", ) + def get_queryset(self, request): + # Admin changelists/autocomplete render metadata only. Keep the complete + # upload body out of those page-sized query results; the change-page + # preview intentionally resolves the one deferred body it displays. + return super().get_queryset(request).defer("raw_text", "user_agent") + @admin.display(description="Raw text (preview)") def raw_text_preview(self, obj): """Bounded, read-only preview of the uploaded JSONL. @@ -167,6 +174,78 @@ class AuditEventAdmin(admin.ModelAdmin): ) autocomplete_fields = ("audit_file", "group") show_full_result_count = False + # Raw evidence is immutable forensic input and can be as large as the + # complete quarantined upload. Keep it out of Django's generated form and + # expose one bounded line preview plus an intentional link to the complete + # evidence response. + exclude = ( + "raw_line", + "raw_event", + "raw_kind", + "raw_context", + "context_human_action", + "context_transport", + "context_engine", + "context_group", + "context_convergence", + "context_source", + ) + readonly_fields = ("raw_line_preview", "evidence_link") + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .defer( + "raw_line", + "raw_event", + "raw_kind", + "raw_context", + "context_human_action", + "context_transport", + "context_engine", + "context_group", + "context_convergence", + "context_source", + ) + ) + + @admin.display(description="Raw line (preview)") + def raw_line_preview(self, obj): + if obj is None or obj.pk is None: + return "\u2014" + # Slice in SQL: resolving the deferred TextField first would still pull + # a quarantined 50 MiB upload into the worker merely to render 2,000 + # characters of it. + row = ( + AuditEvent.objects.filter(pk=obj.pk) + .annotate( + preview=Substr("raw_line", 1, RAW_TEXT_PREVIEW_CHARS), + total_chars=Length("raw_line"), + ) + .values("preview", "total_chars") + .get() + ) + preview = row["preview"] or "" + total_chars = row["total_chars"] or 0 + notice = ( + f"Showing first {RAW_TEXT_PREVIEW_CHARS} of {total_chars} characters." + if total_chars > RAW_TEXT_PREVIEW_CHARS + else f"{total_chars} characters." + ) + return format_html( + '
{}
{}',
+ notice,
+ preview,
+ )
+
+ @admin.display(description="Complete evidence")
+ def evidence_link(self, obj):
+ if obj is None or obj.pk is None:
+ return "\u2014"
+ url = reverse("api-event-evidence", kwargs={"event_id": obj.pk})
+ return format_html('Open JSON evidence', url)
def lookup_allowed(self, lookup, value, request):
if lookup == "audit_file__id__exact":
@@ -181,6 +260,9 @@ class AnalysisRunAdmin(admin.ModelAdmin):
autocomplete_fields = ("group", "created_by")
readonly_fields = ("created_at",)
+ def get_queryset(self, request):
+ return super().get_queryset(request).defer("report_json")
+
@admin.register(DeliveryArtifact)
class DeliveryArtifactAdmin(admin.ModelAdmin):
diff --git a/forensics/analysis.py b/forensics/analysis.py
index 5c2e9d0..f9df396 100644
--- a/forensics/analysis.py
+++ b/forensics/analysis.py
@@ -128,6 +128,11 @@ def audit_files_for_group(group):
# both the groups (M2M) and events relations.
return (
AuditFile.objects.filter(groups=group)
+ # ``raw_text`` can be tens of MiB and none of the group metadata views
+ # consume it. Loading it here made every overview/evidence/export
+ # metadata query retain the full body of every related upload. The raw
+ # body remains available through the dedicated detail/download paths.
+ .defer("raw_text", "user_agent")
.annotate(
group_event_count=Count("events", filter=Q(events__group=group), distinct=True),
)
diff --git a/forensics/ingest.py b/forensics/ingest.py
index b8f73b1..c967e06 100644
--- a/forensics/ingest.py
+++ b/forensics/ingest.py
@@ -8,6 +8,7 @@
from dataclasses import dataclass
from typing import Any
+from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.backends.base.operations import BaseDatabaseOperations
from django.db.models import Case, IntegerField, Value, When
@@ -128,6 +129,10 @@ class ParsedLine:
errors: list[str]
+class AuditLogComplexityError(ValueError):
+ """The upload is byte-bounded but too complex to materialize safely."""
+
+
def iter_jsonl_record_lines(raw_text: str) -> Iterator[str]:
"""Yield JSONL records split only on the LF record delimiter.
@@ -190,11 +195,37 @@ def ingest_audit_log_bytes(
)
file_sha256 = hashlib.sha256(dump_bytes).hexdigest()
- existing = AuditFile.objects.filter(file_sha256=file_sha256).first()
+ existing = (
+ AuditFile.objects.defer("raw_text", "user_agent").filter(file_sha256=file_sha256).first()
+ )
if existing is not None:
return IngestionResult(audit_file=existing, created=False)
- parsed_lines = parse_jsonl(raw_text)
+ try:
+ parsed_lines = parse_jsonl(raw_text)
+ except AuditLogComplexityError as exc:
+ return save_invalid_upload(
+ fallback_group_slug=fallback_group_slug,
+ fallback_group_name=fallback_group_name,
+ upload_token=upload_token,
+ uploaded_by=uploaded_by,
+ source_ip=source_ip,
+ user_agent=user_agent,
+ source_name=source_name,
+ source_account_label=source_account_label,
+ source_device_label=source_device_label,
+ source_device_id=source_device_id,
+ source_device_name=source_device_name,
+ source_platform=source_platform,
+ source_app_version=source_app_version,
+ source_upload_trigger=source_upload_trigger,
+ source_account_pubkey_hex=source_account_pubkey_hex,
+ source_account_npub=source_account_npub,
+ content_type=content_type,
+ dump_bytes=dump_bytes,
+ raw_text=raw_text,
+ error=f"audit log exceeds safe processing limits: {exc}",
+ )
metadata = file_metadata(parsed_lines)
# Account identity is sourced from the JSONL body (source_context), not from
# upload headers. Backfill the per-file account fields so file-level views
@@ -257,7 +288,7 @@ def ingest_audit_log_bytes(
refresh_group_rollups(locked_group_ids)
return IngestionResult(audit_file=audit_file, created=True)
except IntegrityError:
- audit_file = AuditFile.objects.get(file_sha256=file_sha256)
+ audit_file = AuditFile.objects.defer("raw_text", "user_agent").get(file_sha256=file_sha256)
return IngestionResult(audit_file=audit_file, created=False)
except Exception as exc:
# Defense-in-depth: any *other* failure while creating events (e.g. a
@@ -323,7 +354,9 @@ def save_invalid_upload(
error: str,
) -> IngestionResult:
file_sha256 = hashlib.sha256(dump_bytes).hexdigest()
- existing = AuditFile.objects.filter(file_sha256=file_sha256).first()
+ existing = (
+ AuditFile.objects.defer("raw_text", "user_agent").filter(file_sha256=file_sha256).first()
+ )
if existing is not None:
return IngestionResult(audit_file=existing, created=False)
fallback_group = group_for_slug(fallback_group_slug, fallback_group_name)
@@ -367,16 +400,32 @@ def save_invalid_upload(
AuditGroup.objects.filter(id=fallback_group.id).update(updated_at=timezone.now())
return IngestionResult(audit_file=audit_file, created=True)
except IntegrityError:
- audit_file = AuditFile.objects.get(file_sha256=file_sha256)
+ audit_file = AuditFile.objects.defer("raw_text", "user_agent").get(file_sha256=file_sha256)
return IngestionResult(audit_file=audit_file, created=False)
def parse_jsonl(raw_text: str) -> list[ParsedLine]:
parsed_lines = []
for line_number, raw_line in enumerate(iter_jsonl_record_lines(raw_text), start=1):
+ # UTF-8 can use more than one byte per character. Avoid encoding an
+ # already-obviously-oversized line, then enforce the exact byte ceiling.
+ # Check before stripping/skipping so a whitespace-only line cannot
+ # bypass the per-record allocation bound.
+ if (
+ len(raw_line) > settings.GOGGLES_MAX_JSONL_LINE_BYTES
+ or len(raw_line.encode("utf-8")) > settings.GOGGLES_MAX_JSONL_LINE_BYTES
+ ):
+ raise AuditLogComplexityError(
+ f"line {line_number} exceeds maximum of "
+ f"{settings.GOGGLES_MAX_JSONL_LINE_BYTES} UTF-8 bytes"
+ )
raw_line = raw_line.strip()
if not raw_line:
continue
+ if len(parsed_lines) >= settings.GOGGLES_MAX_DUMP_RECORDS:
+ raise AuditLogComplexityError(
+ f"record count exceeds maximum of {settings.GOGGLES_MAX_DUMP_RECORDS}"
+ )
data = None
errors = []
try:
diff --git a/forensics/management/commands/rebuild_audit_projections.py b/forensics/management/commands/rebuild_audit_projections.py
index 2dad5a5..a50f1f5 100644
--- a/forensics/management/commands/rebuild_audit_projections.py
+++ b/forensics/management/commands/rebuild_audit_projections.py
@@ -100,7 +100,7 @@ def groups_from_audit_file_ids(audit_file_ids: list[int]) -> list[AuditGroup]:
seen_group_ids = set()
for audit_file_id in audit_file_ids:
try:
- audit_file = AuditFile.objects.get(id=audit_file_id)
+ audit_file = AuditFile.objects.defer("raw_text", "user_agent").get(id=audit_file_id)
except AuditFile.DoesNotExist as exc:
raise CommandError("No audit file matched one of the requested ids.") from exc
group_ids = set(audit_file.groups.values_list("id", flat=True))
diff --git a/forensics/projections.py b/forensics/projections.py
index 5526863..5c70dea 100644
--- a/forensics/projections.py
+++ b/forensics/projections.py
@@ -54,6 +54,53 @@ class MessageRef:
"unrecoverable",
}
+# Projection work must never hydrate the verbatim per-line JSON or the parent
+# AuditFile row. In particular, ``select_related("audit_file")`` repeats the
+# complete AuditFile.raw_text once for every event in the SQL result: a 50 MiB
+# upload with 1,000 events becomes roughly 50 GiB before projection even starts.
+# Keep this list explicit so projection helpers can use normal model attributes
+# without triggering deferred-field queries while the heavy evidence columns
+# remain outside the worker heap.
+PROJECTION_EVENT_FIELDS = (
+ "id",
+ "audit_file_id",
+ "group_id",
+ "line_number",
+ "wall_time_ms",
+ "engine_id",
+ "account_ref",
+ "audit_data_mode",
+ "event_type",
+ "context_convergence",
+ "context_transport",
+ "human_action_message_ids",
+ "msg_id",
+ "outbound_msg_id",
+ "outbound_welcome_msg_ids",
+ "invalidated_msg_id",
+ "raw_kind",
+ "relay_urls",
+ "accepted_relay_urls",
+ "failed_relays",
+ "required_acks",
+ "met_required_acks",
+ "epoch",
+ "pending_epoch",
+ "payload_len",
+ "payload_digest",
+ "outcome",
+ "outcome_kind",
+ "reason",
+ "new_state",
+ "pending_kind",
+ "result_kind",
+ "selected_branch_id",
+ "selected_fork_epoch",
+ "selected_tip_epoch",
+ "current_tip_epoch",
+ "max_rewind_commits",
+)
+
@dataclass
class ProjectionState:
@@ -98,7 +145,7 @@ def project_file_events(audit_file: AuditFile, group_ids: list[int]) -> None:
AuditGroup.objects.select_for_update().filter(id__in=group_ids).order_by("id")
)
events = list(
- AuditEvent.objects.select_related("audit_file")
+ AuditEvent.objects.only(*PROJECTION_EVENT_FIELDS)
.filter(
structural_quarantine_exclusion(),
audit_file=audit_file,
@@ -168,7 +215,7 @@ def groups_with_out_of_order_convergence_backfill(
"context_convergence",
)
)
- for event in stored:
+ for event in stored.iterator(chunk_size=2_000):
if event.group_id in backfilled:
continue
if not event_may_need_inferred_convergence_state(event):
@@ -242,12 +289,23 @@ def run_is_active_inferred(run: ConvergenceRun) -> bool:
# Only the run's most recent evidence row decides whether it is still open,
# so fetch just that row instead of loading the whole evidence set. NULL
# ``wall_time_ms`` sorts last to match the projection ordering above.
- last_event = run.evidence_events.order_by(
- F("wall_time_ms").asc(nulls_last=True),
- "engine_id",
- "line_number",
- "id",
- ).last()
+ last_event = (
+ run.evidence_events.only(
+ "id",
+ "event_type",
+ "new_state",
+ "wall_time_ms",
+ "engine_id",
+ "line_number",
+ )
+ .order_by(
+ F("wall_time_ms").asc(nulls_last=True),
+ "engine_id",
+ "line_number",
+ "id",
+ )
+ .last()
+ )
if last_event is None:
return True
return not inferred_convergence_event_is_terminal(last_event, run.phase)
@@ -266,7 +324,7 @@ def rebuild_locked_group_projections(group: AuditGroup) -> None:
"""
clear_group_projections(group)
events = (
- AuditEvent.objects.select_related("audit_file")
+ AuditEvent.objects.only(*PROJECTION_EVENT_FIELDS)
.filter(
structural_quarantine_exclusion(),
group=group,
@@ -275,7 +333,7 @@ def rebuild_locked_group_projections(group: AuditGroup) -> None:
.order_by("wall_time_ms", "engine_id", "line_number", "id")
)
state = ProjectionState(active_inferred_convergence_runs={})
- for event in events:
+ for event in events.iterator(chunk_size=500):
project_event(event, state)
@@ -465,7 +523,7 @@ def project_network_observation(
wire = wire_for_event(event, kind)
message_id = artifact.artifact_id if artifact else str(kind.get("msg_id") or event.msg_id or "")
NetworkObservation.objects.create(
- group=event.group,
+ group_id=event.group_id,
artifact=artifact,
audit_event=event,
direction=network_direction(event.event_type),
@@ -575,7 +633,7 @@ def project_convergence(event: AuditEvent, state: ProjectionState | None = None)
if state:
state.active_inferred_convergence_runs.pop(inferred_convergence_key(event), None)
run, _created = ConvergenceRun.objects.get_or_create(
- group=event.group,
+ group_id=event.group_id,
engine_id=event.engine_id,
run_id=run_id,
defaults={"account_ref": event.account_ref, "inferred": False},
@@ -600,7 +658,7 @@ def inferred_convergence_run_for_event(
if active is not None:
return active
run = ConvergenceRun.objects.create(
- group=event.group,
+ group_id=event.group_id,
engine_id=event.engine_id,
run_id=f"inferred-{event.engine_id}-{event.id}",
account_ref=event.account_ref,
@@ -730,7 +788,7 @@ def project_convergence_decision(run: ConvergenceRun, kind: dict[str, Any]) -> N
def project_state_delta(event: AuditEvent) -> None:
kind = event.raw_kind if isinstance(event.raw_kind, dict) else {}
StateDelta.objects.create(
- group=event.group,
+ group_id=event.group_id,
audit_event=event,
epoch=event.epoch,
change_kind=str(kind.get("change_kind") or event.outcome_kind or ""),
@@ -750,7 +808,7 @@ def project_state_delta(event: AuditEvent) -> None:
def project_epoch_transition(event: AuditEvent) -> None:
EpochStateTransition.objects.create(
- group=event.group,
+ group_id=event.group_id,
audit_event=event,
engine_id=event.engine_id,
account_ref=event.account_ref,
diff --git a/forensics/templates/forensics/partials/group_files.html b/forensics/templates/forensics/partials/group_files.html
index 5368a52..88f4bd1 100644
--- a/forensics/templates/forensics/partials/group_files.html
+++ b/forensics/templates/forensics/partials/group_files.html
@@ -1,6 +1,9 @@
Showing the first {{ tab_event_limit }} audit files.
+ {% endif %} {% if audit_files %}