Repository navigation
Bulk Download
POST /api/bulk-downloads zips many documents into one download. One id is a direct presigned
download, never a job. Two or more start a queue-worked job the caller polls until it is ready,
or stops with DELETE /api/bulk-downloads/{id}.
POST /api/bulk-downloads
{ "documentIds": ["<id>", "<id>", "..."] }documentIds must be a non-empty array of non-empty strings. Any other body key is a 400. Ids the
caller cannot see are dropped by the read ACL, so the job's documentCount may be lower than the
number asked for.
200, same shape as GET /api/documents/{id}/download, tagged single: true. No job, no queue.
{ "url": "...", "expiresIn": 300, "fileName": "...", "displayName": "...", "single": true }202
{
"id": "6f1c4b9e-0d2a-4d9d-9c3e-1f5f9a2b7c40",
"status": "queued",
"documentCount": 42,
"estimatedPartCount": 1,
"statusUrl": "/api/bulk-downloads/6f1c4b9e-0d2a-4d9d-9c3e-1f5f9a2b7c40"
}estimatedPartCount is a guess made from the sizes on the document rows. The count of parts the
job produces is partCount on the status body — see Part splitting below.
| Status | When |
|---|---|
| 400 | Unknown body key, malformed documentIds, more documents than the caller's cap, or the selection is over BULK_MAX_TOTAL_BYTES
|
| 404 | None of the requested documents are visible to this caller |
| 429 | This requester already has BULK_MAX_PENDING jobs queued or running, or has started BULK_MAX_PER_DAY jobs in the last 24 hours |
| 503 | Bulk download is off in this environment (BULK_DOWNLOADS_QUEUE unset), or the job was recorded but could not be queued (marked failed immediately rather than left queued forever) |
GET /api/bulk-downloads/{id}
{
"id": "6f1c4b9e-0d2a-4d9d-9c3e-1f5f9a2b7c40",
"status": "ready",
"documentCount": 42,
"partCount": 1,
"partsReady": 1,
"includedCount": 41,
"errorCount": 1,
"errors": ["<id>\t<name>\tnot available"],
"bytes": 104857600,
"parts": [
{ "n": 1, "url": "https://...", "bytes": 104857600, "count": 41, "expiresIn": 300, "fileName": "EPIC documents - Site C Clean Energy.zip" }
]
}parts is present only when status is ready, and partCount is 0 until then — it counts the
parts the worker built. errors on the status body is capped at 100 entries; the full list ships as
errors.txt inside the last zip part.
The job id is the capability. Anyone holding it can poll an anonymous job. A job made by an authenticated caller answers only that caller — a mismatch is 404, never 403, so a foreign or unknown id looks the same either way.
DELETE /api/bulk-downloads/{id} → 204, no body. Same capability rule as polling: whoever may
poll a job may cancel it, and anything else is 404.
A job still queued, running or failed becomes cancelled, and the worker deletes the zip
parts it had already written. failed is included because the queue redelivers such a job and the
worker re-runs it on purpose — otherwise the build would resume up to a visibility timeout after
the cancel. The in-flight slot goes back if the job was still holding it. A job that already
reached ready, expired or cancelled is left as it is and answered 204 all the same, so a
client that cancels twice, or cancels a job that finished a second earlier, needs no special case.
The status flip is a conditional patch on the job row: whichever writer takes the row out of a live
status owns the outcome. That is what stops a cancel arriving as the worker finishes
from overwriting ready, or from freeing the requester's slot a second time — the loser writes
nothing and releases nothing.
The worker checks the row for a cancellation between parts and every 20 documents inside a part, so
a cancel takes effect within a few files rather than at the end of a 2 GiB part. When it sees one it
closes the archive it had open, finishes the upload rather than abandoning the stream, deletes every
part it wrote for the job, logs [bulk] job cancelled <id> and stops. It does not retry and does
not poison the message. A worker that died before it got that far leaves its parts to the cleanup
timer, which sweeps cancelled jobs on the same schedule as ready ones.
queued → running → ready, failed or cancelled. A ready or failed job becomes
expired when the cleanup timer deletes its zip parts.
-
queued: row saved, message on the queue. -
running: worker dequeued the message and is streaming files. Arunningjob whose worker has not reported in over the queue's visibility timeout (host.jsonextensions.queues.visibilityTimeout, 1 hour) reads asfailedon the next poll, even though the row itself is not patched by the poll. A worker that later dequeues the redelivered message reclaims that same stale row (a conditional replace keyed on the row's etag) and runs it again from scratch; whichever instance loses the race gets a conditional-write conflict and walks away. -
ready: every part is built and uploaded.parts[]holds one presigned URL per part. -
failed: the worker threw, or the job sat on the queue pastBULK_MAX_JOB_AGE_MS(default 2 hours) and was refused without running — its access snapshot is that stale too. A thrown error is retried up tomaxDequeueCount(3) before it lands on the poison queue; a retry re-runs afailedjob from scratch on purpose, so a transient object-store outage is self-healing. An expired job is not retried. -
cancelled: the caller stopped the job withDELETE. Terminal — nothing writes over it, the cleanup timer included: a swept cancelled row keeps its status and only loses thepartsit named, which is also what takes it out of the sweep's query. The worker deletes those parts when it stops, and the sweep deletes whatever a worker that died first left behind. The row itself goes when its TTL runs out. -
expired: the cleanup timer deleted the zip parts past retention. The job row survives past this point so a poll answersexpiredrather than a 404 that reads as "never existed".
A single missing or unpublished document never fails the job: it is recorded in errors[] and
errors.txt, and the rest of the zip still builds. The job itself fails if the object store streams
more bytes than BULK_MAX_TOTAL_BYTES allows — the upfront check against fileSize only estimates;
the running byte count from the archive is what the worker enforces while it builds.
Cosmos TTL (defaultTtl: -1, per-item ttl) deletes the job row itself after BULK_JOB_TTL_DAYS
(default 30), independent of the cleanup timer that removes the zip objects after
BULK_ZIP_RETENTION_DAYS (default 7).
| Setting | Default | Meaning |
|---|---|---|
BULK_MAX_DOCUMENTS |
2500 | Most documents one authenticated job may ask for. Over it, 400. |
BULK_ANON_MAX_DOCUMENTS |
100 | Same cap for an anonymous caller. |
BULK_MAX_BYTES |
2147483648 (2 GiB) | Ceiling per zip PART. A job over it is split into numbered parts, not refused. A single file over the cap becomes its own part. |
BULK_MAX_TOTAL_BYTES |
21474836480 (20 GiB) | Ceiling for the whole job. The one limit that refuses (400) rather than splits. |
BULK_FETCH_AHEAD |
3 | Objects the worker opens ahead of the entry it is writing. Higher overlaps more round trips and holds more sockets and buffers open on one instance at a time. |
BULK_FETCH_CONCURRENCY |
1 | Parked objects fetched in parallel. 1 leaves the feature off. |
BULK_FETCH_BUFFER_BYTES |
268435456 (256 MiB) | Total bytes held in memory for parked objects. |
BULK_MAX_PENDING |
3 | Concurrent jobs (queued or running) per requester. No app rate limiter exists and APIM Consumption cannot key-limit an anonymous caller, so this is the abuse boundary for the feature. |
BULK_MAX_PER_DAY |
20 | Jobs one requester may start in a rolling 24 hours. BULK_MAX_PENDING alone bounds concurrency only — a caller who waits for each job to finish before starting the next could otherwise start an unlimited number. |
BULK_MAX_JOB_AGE_MS |
7200000 (2 hours) | How long a job may sit on the queue before the worker refuses it outright, marking it failed without running. The job's access snapshot is a copy of the caller's roles at submit time, and this is the ceiling on how stale that copy may be when it is acted on. |
BULK_ZIP_RETENTION_DAYS |
7 | Days a ready, failed or cancelled job's zip parts stay in object storage before the cleanup timer deletes them. |
BULK_JOB_TTL_DAYS |
30 | Days the Cosmos job row lives (ttl), always longer than the zip retention. |
BULK_DOWNLOADS_QUEUE |
unset | Storage queue name. Empty means the feature is off: no worker registers, POST answers 503. |
BULK_CLEANUP_SCHEDULE |
unset | NCRONTAB for the cleanup timer. Empty AND BULK_DOWNLOADS_QUEUE set defaults the schedule to 0 30 3 * * *; empty with no queue set registers no timer. |
All thirteen are src/config.js app settings, plumbed through
azure/modules/api-function-flex.bicep. Each is parsed with a helper that throws at process start
on a value that is not a whole non-negative number, so a typo in an app setting fails the deploy
rather than silently removing the limit.
A request takes an in-flight slot when it clears the quota check and gives it back exactly once: on
a refusal, on a server error, when the job is ready, when it is cancelled, or when its last
delivery fails. A failure with a retry left keeps the slot, because that job is still going to run.
slotReleasedAt on the job row is what stops a job freeing a slot twice: the worker, a cancel and
the cleanup sweep each write that stamp under a condition only one of them can satisfy, and only
the writer that lands it touches the counter.
-
Zip file name:
EPIC documents - <project name>.zipwhen every document in the job comes from one project,EPIC documents - <N> projects.zipwhen it spans several. A job built as more than one part adds(part <n> of <partCount>)before the extension. The name is baked into the presigned URL as its content-disposition; the storage key is unrelated to it. A job whose single project has no name this caller may see is namedEPIC documents.zip. -
One folder per project inside the zip, named from the project's
name. A document whose project cannot be resolved goes inunknown-project/. -
File name: the document row goes through the same field redactor as any other document read first, so a name withheld from this caller cannot leak into the zip as a file name. From what survives that:
documentFileName, falling back todisplayName, falling back to the document id if both were empty or fully redacted. Cleaned of path separators and control characters, capped at 150 characters, and given an extension fromfileExtif it has none. -
A name collision inside the same folder (case-insensitive — Windows and macOS cannot extract two entries differing only in case into one folder) gets
(2),(3), and so on before the extension. -
Zips are streamed with
zip-stream(STORE, uncompressed) straight into the multipart upload — nothing buffers a whole zip in memory. -
errors.txtis written into the LAST part only, one line per skipped or damaged document:<documentId>\t<name>\t<reason>. Reasons are a fixed vocabulary, never the object store's own message:Reason Meaning not availableDocument row not found for this manifest read: unpublished since submit, deleted, or never visible to this caller — one reason, so the message does not itself disclose which. no object keyDocument row found but has no stored file ( s3Keyempty).unavailableThe object could not be opened at all. truncatedThe object opened but the stream failed partway through. The entry is still written to the zip with whatever bytes arrived — it is not dropped, only excluded from includedCount.
The worker packs parts while it streams, in the order the caller submitted the ids. It opens up to
BULK_FETCH_AHEAD objects ahead of the entry it is writing, so the next documents' round trips to
object storage overlap the current one, while the entries themselves are still written one at a
time in the submitted order. A stream that resets while it waits its turn is opened once more
before it counts as that document's error. Before adding
the next document it starts a new part if the current one has already written BULK_MAX_BYTES, or
if that document's recorded fileSize would carry it over. A document bigger than the cap gets a
part of its own rather than a refusal. Documents with no recorded size pack together like any
other: the bytes the archive has written, not the sizes on the rows, are what close a part.
errors.txt goes into the last part built.
estimatedPartCount on the 202 comes from a size-only pack (packParts in
src/jobs/pack-parts.js), which counts a missing fileSize as nothing, so it is an estimate and
named as one. partCount on the status body is what the worker actually built.
- The controller snapshots the caller's resolved access (
resolveAccess(req)) onto the job row at submit time. The worker re-runs the document read with that same snapshot rather than trusting the submitted id list, so a document unpublished between submit and run is dropped and listed inerrors.txt. Sealed-compartment documents are excluded the same way a normal read excludes them — the worker never passes a compartment and never usessystemAccess(). - Roles in the snapshot are frozen at submit time on purpose — that is the selection the caller
asked for. Credentials are different: before running, the worker re-checks each credential in the
snapshot against the live registry and drops any that have since been revoked or expired. The
re-check covers every party the request loaded grants for — the caller's subject, their API key
id, and each realm group they belong to, stored on the job row as
parties— so a grant held by a group survives it. This only narrows the snapshot, never widens it. - An anonymous job is pure bearer: whoever holds the job id can poll it. An authenticated job binds to the caller's key id, subject, or username at submit time; anyone else polling it gets 404.
- The per-requester quota (
BULK_MAX_PENDING,BULK_MAX_PER_DAY) keys on the same identity: an API key id or signed-in subject when present, otherwise the caller's IP address (src/utils/caller-ip.js). Behind APIM the lastX-Forwarded-Forhop is APIM itself, which would make every public caller share one quota bucket, so the app trusts one of two hops instead, each proven by a header only the platform that appended it could have set.-
Front Door first. eagle-edge (profile
eagle-edge-<env>) overwritesX-Edge-Secreton every request it forwards, before APIM ever sees it. When that header matchesEDGE_SECRET, the key isX-Azure-SocketIP— the peer Front Door itself accepted the connection from — notX-Azure-ClientIP, which Front Door derives from the caller's ownX-Forwarded-Forand so any browser can pick. See Environment-Reality-and-Operational-Gotchas § Front Door edge secret for where the value lives. -
APIM otherwise. APIM asserts the address it saw as
X-Client-Ipon every request, trusted only when the request also carries the gateway secret APIM sets alongside it (azure/modules/apim.bicep). - Either way, once an asserted address (
X-Azure-SocketIPorX-Client-Ip) is itself a proxy WE run (TRUSTED_PROXY_IPS, comma-separated addresses or CIDRs — the OpenShift rproxy that fronts eagle-public), every visitor behind it would still share one bucket. The key is then the firstX-Forwarded-Forhop to the left of that address that is not a trusted proxy — anchored at the asserted hop's own position in the chain, not the rightmost entry, because Front Door and APIM both append to the same header and the browser's hop sits to the left of both. Everything further left is caller-supplied and never trusted. - When the asserted address is not a trusted proxy, that address is the key directly.
- A request that reaches the app directly, bypassing both Front Door and APIM, cannot present
either secret and falls back to the last
X-Forwarded-Forhop the platform itself appended. - To measure the address to add to
TRUSTED_PROXY_IPSin an environment, read the egress address of the OpenShift rproxy pod for that environment (eagle-api's outbound IP), or the pod network CIDR if the egress address is not stable. In prod this is still unmeasured —TRUSTED_PROXY_IPSis empty there, so the Front Door path changes nothing until it is.
-
Front Door first. eagle-edge (profile
| Resource | Name |
|---|---|
| Queue |
bulk-downloads (test), set by the bulkDownloadsQueue bicep param — empty in prod today |
| Poison queue |
bulk-downloads-poison, created automatically alongside the main queue |
| Cosmos container |
bulkDownloads, partition key /id. defaultTtl: -1; indexed paths /status, /requesterKey, /finishedAt only — the id lists, access snapshot, errors and parts arrays are excluded from the index |
| Poison alert |
demi-bulk-download-failed-<env>, deployed when deployBulkDownloadPoisonAlert is true. Severity 2 (warning), fires on any AppTraces row containing [bulk] job failed in a 15-minute window |
host.json queue settings for this trigger: messageEncoding: "none", batchSize: 1,
newBatchThreshold: 0, maxDequeueCount: 3, visibilityTimeout: "01:00:00". batchSize: 1 means
one zip builds per Function App instance at a time; Flex scales horizontally (up to 20 instances)
instead of running several zips per instance, which bounds the multipart upload buffer each instance
holds in memory.
The worker logs [bulk] job failed <id>: ... only on the delivery that will actually poison the
message (the last of maxDequeueCount attempts) — an earlier attempt logs [bulk] attempt failed
instead, so the alert above does not fire for a job the queue is still going to retry.
The job id is masked out of the per-request access log (GET|POST /api/bulk-downloads/<id>) because
it is a bearer capability: logging it would let anyone who can read the logs poll or, once ready,
download the job.
- Poll
GET /api/bulk-downloads/{id}every few seconds untilstatusisready,failed,expiredorcancelled. - Trigger each part's download with same-tab navigation (
location.assign(url), or a synthetic<a>click withouttarget=_blank) — notwindow.open, which needs a user gesture Safari blocks on an async response. The presigned URL carriesContent-Disposition: attachmentalready, so this downloads rather than navigates. - More than one part fires the browser's "allow multiple downloads" prompt once; fire the URLs sequentially.
- Keep the job id in
localStorageand resume polling on page reload — closing the tab does not cancel the job, only the caller's ability to notice it finished.DELETEis the only cancel.
- Bulk-Download-Operations — enable in an environment, poison-queue runbook
-
Environment-Reality-and-Operational-Gotchas — queue trigger encoding,
s3Keyvisibility -
Attribute-Level-Access — how
resolveAccessand the read ACL work - Architecture — where the object store and Cosmos containers fit overall
- Environment Reality & Gotchas
- Architecture
- Seeding — filling Cosmos from Track and Eagle
- Sources and Status — every source, one state
- Search & Retrieval — overview
- Search Index Reference
- Search Query Construction
- Search Measurements
- Extraction Pipeline
- Extraction Quality — coverage, word-joining, tiling
- Identity Model — which id means what
- Attribute-Level Access — field-visibility levels, catalog
- Sync Architecture — how records get in
- Eagle Mirror Reference — mirrored kinds, refusals, drift classes
- ENGAGE Rows Reference — ENGAGE-owned comment periods, reconcile
- Public-Read Backfill — filling and repairing the mirror containers
-
Short Links —
/s/{code}redirect and/api/links -
Recent Uploads —
/api/documents/recent-uploads, eagle-public homepage panel - Reports — comment export and BCGW CSV reads
-
User Data — saved map areas, preferences,
/meroutes -
Bulk Download —
/api/bulk-downloads, zip jobs - Bulk Download: Operations — enable, poison-queue runbook
- Document Intake — upload link, malware scan, release
- Connecting an Application to DEMI
- Azure Environments
- Document Backup — archive copy of originals, run and check
- Document Backup: Revert and Drill — put documents back from the archive
- PDF Titles — public name written into stored PDFs, run, restore
- Secret Sync — Key Vault, secret-sync Function app, required names
-
Operating DEMI in Azure — deploy, verify
- Operating the DEMI Data Plane — devbox access, probes, audit and cost reads
- Edge ban detector — hourly timer, modes, kill switch, soak and promote
-
CI Workflows — every GitHub Actions workflow, trigger, identity, roles
- CI Deploy Workflows — eagle-demi staging and production deploys
- CI Admin Frontend Workflows — eagle-demi-admin checks and deploys
- Release Process — cutting, tagging, shipping and rolling back a version
- Runbook: Search Outage — index/app schema drift, diagnose, fix, roll back
- BC Gov Landing Zone
- Business Rules
- EPIC.search Business Rules — where DEMI differs from EPIC.search
- 001: Typesense for DFL Search — superseded
- 002: Async Processing (MongoDB Queue) — superseded
- 003: Rule-Based Classification
- 004: Read ACL Authorization Model
- 005: Cosmos Full-Text Search Ruled Out
- 006: AI Summarization over BM25
- 007: Service-to-Service Credentials
- 008: Audit and Usage Analytics Store
- 009: Track Feed — proposed
- 010: Attribute-Level Access Control
- 011: Public Read Surface
- 012: Public Config Surface
- 013: Stored Project Summaries — proposed
- 014: Sync-out to Eagle
- 015: Malware scanning on intake