Batch buffered account-export artefact blob loads (#1355) - #1385
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces batch-loading of blob content for requested artefacts in SourceArtefactRepository and integrates it into DataExportService to avoid database round-trips, supported by comprehensive integration and unit tests. Feedback suggests addressing an N+1 query issue with GetAllExtractionHistoryAsync inside the loop, simplifying the chunking logic using LINQ's .Chunk() method, and adding a guard clause in the repository to prevent SQLite parameter limit violations if called with more than 999 IDs.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Adversarial Code ReviewReviewed the full diff (production + tests) against origin/main, with focus on ordering stability across chunks, SQLite parameter-limit edges, empty-set validity, user-scoping, byte-for-byte export parity, and memory posture. CRITICAL
HIGH
MEDIUM
LOW
Bot Comments Addressed
Summary0 CRITICAL, 0 HIGH, 2 MEDIUM, 1 LOW. Not merge-blocking on correctness; fixing all three (M1, M2, L1) now per zero-skip policy. Fix evidence to follow. |
Adversarial Review — Fixes AppliedAll self-review and bot findings addressed. Verified via targeted suites (38 export-service tests in Self-review findings
Bot findings (gemini-code-assist)
Notes
CI status: pending at time of writing; it will be re-verified green before any merge. Merge is the maintainer's call — this PR never self-merges. |
Consolidated Adversarial Review — Two Independent LensesCoordinator adjudication of two independent reviews of this PR (query/GDPR-scoping lens; test-discrimination lens). Lens 1 — Query & GDPR scoping: FULL REFUTATION (no findings)Every attack angle was probed and refuted:
Lens 2 — Test discrimination: all claims confirmed; 4 LOW findings
Summary0 CRITICAL / 0 HIGH / 0 MEDIUM / 4 LOW + 1 annotation. Not merge-blocking; all four LOWs and the annotation are being fixed now per zero-skip policy. Fix evidence to follow. |
…nnotate wiring-only scoping test
Consolidated Review — Fixes AppliedAll four LOW findings and the annotation from the two-lens consolidated review are fixed, pushed as
Verification (exact counts)
Lens 1 (query/GDPR scoping)No findings — full refutation across scoping equivalence, dictionary semantics, ordering, parameter limits, transactionality, and guard reachability (see the consolidated review comment above for the list). Nothing to fix on that lens. Inline bot threads (gemini-code-assist) are being replied to and resolved with commit references next. |
* docs: record 2026-07-17 overnight delivery wave in STATUS + masterplan * docs(failure-ledger): mark #1282/#1347/#1348 resolved and re-render * docs: fold late-landing #1385/#1381/#1390 into the 2026-07-17 overnight entry * docs: separate Apply-considerations copy from the Operation-safety confidence label (Gemini M1/M2)
Closes #1355
What & why
The buffered
/api/account/exportpath (DataExportService.ExportUserDataAsync) loaded artefact blobs one round-trip per artefact viaISourceArtefactRepository.GetContentForUserAsync— up to ~10,000 SELECTs for a heavy export (LOW-2 from the #1341 GEN-01 security review). This batches the blob loads while preserving the export contract byte-for-byte.Design
GetContentsForUserAsync(IReadOnlyCollection<Guid> ids, Guid userId, ct)→IReadOnlyDictionary<Guid, byte[]>: a single keyed IN-query using the same user-scoped artefact→blob join asGetContentForUserAsync(so a foreign artefact id can never surface content). Empty id set short-circuits with no query. Mirrors the existingChatMessageRepository.CountBySessionIdsAsyncbatching precedent.DataExportServicenow pages the artefact metadata (already Id-ordered) in bounded chunks ofStreamPageSize(500), batch-loading each chunk's blobs and mapping in metadata order.Chunk size: 500. With the 500-id
IN (...)plus theuserIdparameter that is 501 bound parameters, comfortably underSQLITE_MAX_VARIABLE_NUMBER = 999. Matches theStreamPageSizeconstant already used by the streaming export's chat-session batching.Memory posture: deliberately not one mega-query. Each chunk's raw blob dictionary is processed then released before the next chunk loads, so peak raw-bytes held is one chunk's worth. Total buffered content remains bounded by the pre-existing
MaxBufferedArtefactBytes(10 MB) and 10,000-row guards, which run before any blob load. Round-trips drop from N toceil(N/500).Contract preserved: artefacts are emitted in the exact former (Id) order; a missing blob still throws
InvalidOperationException(→UnexpectedError); user-scoping and the pre-load size guards are unchanged.GetContentForUserAsyncis retained on the interface (symmetry withCopyContentForUserAsync; regression tests assert the buffered path no longer calls it).Tests
Service-level (
DataExportServiceTests, Moq):Times.Never).ids.Count == 500once,ids.Count == 1once — the chunk-size and chunk-size+1 boundary), order preserved across chunks, all content correct, per-item never called.UnexpectedError), contract preserved.Repository integration (
SourceArtefactRepositoryIntegrationTests, real SQLite +DbCommandInterceptor):ArtefactBlobsSELECTs (empty-set validity).Verification
dotnet restore+dotnet build backend/Taskdeck.sln -c Release -m:1→ 0 errors (pre-existing warnings only).DataExportService*+GdprDataExportRoundTrip*(Application.Tests) → 37 passed.SourceArtefactRepositoryIntegrationTests(Api.Tests) → 4 passed.DataPortabilityApiTests(end-to-end export through the real repository) → 12 passed.AccountDeletionServiceTests+ArtefactServiceTests+ArtefactExtractionServiceTests→ 43 passed.dotnet ef migrations has-pending-model-changes→ "No changes" (no schema impact).