Model/docs/adr/0060-bulk-document-download-package-model.md
Khalim Conn-Kowlessar 26857f8df7 Bulk download: match uploaded_files via hubspot_deal_id bridge + add worker observability
A live test returned 'no documents could be packaged' for 91 correctly-resolved
properties. Root cause: no upload source populates uploaded_files.landlord_property_id
(pashub/magic-plan/audit set hubspot_deal_id; ECMK sets hubspot_listing_id), so
matching on landlord_property_id found zero rows.

Match fix: the worker now joins uploaded_files -> hubspot_deal_data (on deal_id) ->
landlord_property_id, taking the property identity from the bridge. The repository
returns a small PropertyDocument read-model instead of the infra ORM row, so the
orchestrator no longer names infrastructure.postgres.* (resolves the leak dancafc
flagged) and the s3_upload_timestamp cast is gone. Coverage is limited to
deal-id-linked sources; listing_id/uprn-only files are a noted follow-up.

Observability: the empty-selection failure now carries stage counts
(selected/matched/planned/skipped) in both the message (-> the worker WARNING log)
and details (-> sub_task.outputs), and the run logs those counts. The next failure
says 'matched 0 documents' instead of failing opaquely.

ADR-0060 + CONTEXT.md updated (matching decision + considered options).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:58:46 +00:00

133 lines
8.2 KiB
Markdown

---
status: accepted (builds on ADR-0055, ADR-0059)
---
# Bulk Document Download builds one capped, best-effort Download Package per request
Users need to pull the documents held in `uploaded_files` for many properties
at once — per property, the latest file of each **Document Type** — as a single
archive, without clicking through the UI file by file. The request is initiated
in the front end, can span a hand-picked set of properties or one or more whole
HubSpot projects (by project code), and finishes with a link emailed to the
requester (ADR-0059).
`uploaded_files` links to a property by `uprn` **or** `landlord_property_id`
(both nullable) and has **no `property_id`** column; `file_type` (the Document
Type) is itself nullable; and files live across arbitrary buckets
(`s3_file_bucket` per row).
## Decision
**A request produces exactly one Download Package: a ZIP of one folder per
property, each holding the latest file of each Document Type — built
best-effort, size-capped at the trigger, on the app-owned-task + attach-mode
lane (ADR-0055).**
- **Trigger & lifecycle (ADR-0055).** The **front end** creates the app-owned
`tasks` row and writes the **selection config**
`{project_codes?: str[], landlord_property_ids?: str[], portfolio_id?: int}`
into `tasks.inputs` (TEXT/JSON, FE-owned), then calls the FastAPI route with
**only the `task_id`**. A large selection therefore never travels in an HTTP
body. The route **reads the selection from `tasks.inputs`** and resolves it to
the distinct `landlord_property_id` set — the **union** of every property in
the named HubSpot `project_codes` (read from `hubspot_deal_data`, where the
project↔property grain lives) and the hand-picked `landlord_property_ids`.
`landlord_property_id` is the selection key throughout because
`uploaded_files` is matched on it (it has no `property_id`). At least one of
the two must be provided; `portfolio_id` is optional and used only to name the
package. The route then caps the set, resolves the recipient email from the
authenticated user (ADR-0059), **pins the resolved recipe**
(`landlord_property_ids`, `recipient_email`, `package_name`) onto **one
pre-created `sub_task`'s `inputs`**, and drops one SQS message (`task_id`,
`sub_task_id`). The new `applications/bulk_document_download` Lambda runs in
**attach mode**, reading that recipe from the sub_task; `TaskOrchestrator`
owns status + roll-up. (`task.inputs` = the FE's selection; `sub_task.inputs`
= the backend's resolved recipe.)
- **One package = one sub_task, streamed.** No fan-out. A Download Package can
be **several GB**, so it is never held whole in memory: each document is read
from its own `s3_file_bucket`, written into an on-disk ZIP in `/tmp`, and
released; the finished archive is **multipart-uploaded from disk**
(`S3Client.upload_file`). The Lambda's ephemeral storage (`/tmp`) is raised
accordingly (up to 10 GB) — see Consequences. One artifact, one URL, one
email.
- **Selection cap at the route.** Reject `> N` properties synchronously with a
legible "narrow your selection" error (property count, not a document/byte
query — cheap, no S3 on the hot path). A `/tmp` size budget inside the Lambda
is a backstop that fails the sub_task with a clear reason if a pathological
selection still overflows. `N` starts at a conservative, tunable value.
- **Matching via `hubspot_deal_id`, bridged through `hubspot_deal_data`.** The
selection is a set of `landlord_property_id`s, but `uploaded_files` is matched
on **`hubspot_deal_id`**: in practice no upload source populates
`uploaded_files.landlord_property_id` (pashub, magic plan and the audit
generator set `hubspot_deal_id`; ECMK sets `hubspot_listing_id`), so the
worker joins `uploaded_files → hubspot_deal_data (on deal_id) → landlord_property_id`
and takes the property identity from the bridge, not the file row. **Coverage
limitation:** files linked *only* by `hubspot_listing_id` (ECMK) or `uprn` are
not reached; extending the bridge to those keys is a follow-up. Property
display info (the folder name) is enriched from the property record
(`repositories/property/`).
- **Layout.** One folder per property named by human-readable **address**
(hubspot-enriched), with `landlord_property_id` appended for uniqueness;
inside, one file per Document Type = the newest by `s3_upload_timestamp`.
Rows with a **null Document Type are skipped** and listed in the run output.
- **Best-effort failure contract.** Build from whatever resolves. Skipped
properties (no documents) and skipped null-type files are recorded in
`sub_task.outputs`. The run **fails only** on an infrastructure error
(S3/DB/zip/upload/email) or when the whole selection yields **zero
documents**. The email carries an "N properties, M documents, X skipped"
summary.
- **Delivery — both channels.** The presigned URL (60-minute expiry) and the
skip-summary are written to `sub_task.outputs` (the FE already polls task
status and can show the link) **and** emailed (ADR-0059).
- **A dedicated exports bucket.** Packages are written to a **separate
`DOCUMENT_EXPORTS_BUCKET`**, not `DATA_BUCKET` — the generated archives are a
distinct class of artifact (transient, user-facing, wide-read source but
single-writer) and keeping them out of the shared data bucket keeps IAM and
any future retention policy cleanly scoped to exports. No lifecycle rule is
imposed on `DATA_BUCKET`. Retention on the exports bucket is an open decision
(the packages are transient, so an expiry is likely warranted, but it is left
to a follow-up rather than baked in here).
## Considered options
- **Fan out into per-batch sub_tasks / multiple ZIPs** (like the modelling
run). Rejected: the user would receive N links / N emails and "the download"
would stop being one file; the capped-single-package model keeps the artifact
and the notification singular.
- **Cap on resolved document count / bytes.** Rejected for v1: it adds
synchronous DB + S3 HEAD work to the trigger and couples the route to the
packaging logic; a property-count cap is cheap and predictable.
- **Strict "all-or-nothing" packaging.** Rejected: one unreadable row or a
property with no documents would deny the entire package; best-effort +
reporting matches "include documents and properties where they exist".
- **Match on `landlord_property_id` directly.** Rejected once tested against
real data: `uploaded_files.landlord_property_id` is unpopulated, so a direct
match returns nothing. `hubspot_deal_id` (bridged via `hubspot_deal_data`) is
the key that upload sources actually set.
- **Match on `uprn` / `hubspot_listing_id` as well.** Deferred: `hubspot_deal_id`
covers the bulk of sources; add `uprn`/`listing_id` to the bridge if a source
keyed only by those needs including.
## Consequences
- New DDD pieces: `applications/bulk_document_download/` (thin handler +
trigger body), `orchestration/bulk_document_download_orchestrator.py`,
packaging rules in `domain/`, a `hubspot_deal_id`-bridged query on the
uploaded-file repository that returns a small `PropertyDocument` read-model
(so the orchestrator never names `infrastructure.postgres.*`), a
`generate_presigned_url` + multipart upload on `S3Client`, and the email
port/adapter from ADR-0059. The only `backend/` touch is the trigger route.
- The stored, pinned `property_id` set makes a run reproducible even if the
portfolio changes between trigger and execution.
- `N`, the Lambda's ephemeral-storage (`/tmp`) size, and its memory are tunable
knobs, not load-bearing invariants. `/tmp` must be raised beyond the 512 MB
default (up to 10 GB) to hold a multi-GB archive — a new terraform knob on the
shared Lambda modules, backward-compatible (every other Lambda keeps 512 MB).
- A **new S3 bucket** (`DOCUMENT_EXPORTS_BUCKET`) is provisioned for the
packages, with its own IAM (single-writer, presign-read). Its retention policy
is a deliberate follow-up, not set here.
- Because matching bridges through `hubspot_deal_data`, a file is only reachable
once its deal has a `hubspot_deal_data` row (and the file carries that
`hubspot_deal_id`). Populating `uploaded_files.landlord_property_id` at upload
time, or closing the `uploaded_files.property_id` gap, would let matching drop
the bridge without changing the package model.