Story ING-002: Scheduler + MinIO inbox client + tenant iteration skeleton¶
Module: ingestion
Slice: Tier 1 sub-slice ING-001-a (architecture §11)
Side: [BACKEND]
Version target: V0.0.0
Priority: 2
Depends on: ING-001 (test infra + empty migrations), TENANT-001 (TenantConfigCache bean + default-agency provisioning), AUDIT-001 (AuditEventBuilder + outbox) and AUDIT catalog extension OQ-X10 admitting ING_UPLOAD_RECEIVED
Can develop concurrently with: ING-003 (pure parser/validator code has no overlap with this slice)
Merge order: Before ING-004
Estimated complexity: S
PRD User Stories: Derived from ING-US-01 (upload pickup pathway; cron substitute in v0.0.0)
Wireframe: N/A — backend only
Objective¶
Wire the nightly @Scheduled cron that iterates active tenants, lists /<tenantId>/inbox/ in the platform MinIO bucket, and emits ING_UPLOAD_RECEIVED audit when a file is picked up. No parsing, no validation, no commit yet — just the orchestration skeleton plus the MinIO wrapper used by all subsequent slices. After this story, the operator dropping a file in MinIO causes an audit row and a processed/ move at the next tick.
Backend Scope¶
Entities¶
None new. This slice does not yet write ingestion_import rows — that arrives with ING-004. It only emits audit events with the MinIO key as subject_id-equivalent metadata.
Migrations¶
None. (V010 body still empty; filled in ING-004.)
Service Layer¶
IngestionScheduler(@Component): single@Scheduled(cron = "${INGESTION_CRON_EXPRESSION:0 0 2 * * *}", zone = "${INGESTION_CRON_ZONE:Africa/Abidjan}")methodtick(). Also exposed via/internal/ingestion/tickPOST endpoint only onlocalprofile for the smoke verification command in architecture §14.3.IngestionService.tick(): iteratesTenantConfigCache.listActiveTenants(), opensTenantContext.scope(tenant.id, tenant.countryCode), callsprocessInbox(tenant)per tenant sequentially (architecture §4.1).IngestionInboxClient(MinIO wrapper):list(tenantId) → Stream<InboxObject>,get(tenantId, key) → InputStream,moveToProcessed(...),moveToRejected(...),moveToDuplicate(...),putErrorsSidecar(...). Keys built only from the iteration-boundtenantId(architecture §2.5, §7.6 threat A4).IngestionAuditEvents.uploadReceived(...): buildsING_UPLOAD_RECEIVEDAuditEventBuilder payload with{ filename, file_size_bytes, file_hash_sha256, minio_inbox_key }— no row content.- In this slice
processInboxflow = list → for each object: read size + stream-hash SHA-256 → emitING_UPLOAD_RECEIVED→ callmoveToProcessed(placeholder behavior; ING-004 replaces with full parse/commit/reject branching).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /internal/ingestion/tick (local profile ONLY) |
— | 204 No Content | None (gated by profile filter) |
Validation Rules¶
- Magic-byte verification (CSV vs XLSX) deferred to ING-003 parsers.
- File-size hard cap (
INGESTION_MAX_FILE_BYTES=5_242_880) enforced at hash-streaming step: if cumulative bytes exceed limit, abort the iteration for that file, emitING_UPLOAD_RECEIVEDwithfile_size_bytesset to the limit + atruncated:trueflag, move file to/<tid>/rejected/with sidecar reasonFICHIER_TROP_VOLUMINEUX. (Note: in ING-004 this becomes a properPARSE_FAILEDrow; in ING-002 there is no DB row yet.)
Multi-Tenant Considerations¶
Cron carries no JWT. Per-iteration TenantContext.scope(tenant.id, tenant.countryCode) is the only tenant-source-of-truth. actor_type=SYSTEM, actor_id="ingestion-cron" on all audit emissions (architecture §4.1, §7.4). MinIO key derivation MUST use the iteration variable only — never a value from file content (architecture §7.6 A4). Both DB profiles work because the only DB writes here are audit-outbox rows on the platform DB (architecture §4.2, D12).
Audit & Logging¶
ING_UPLOAD_RECEIVEDemitted once per object picked up.actor_type=SYSTEM,actor_id=ingestion-cron,subject_type=INGESTION_IMPORT,subject_id=<UUIDv7 minted for the import>(the same UUID is reused as the eventualingestion_import.idonce ING-004 lands; persist it in a thread-local for the iteration). Details:{ filename, file_size_bytes, file_hash_sha256, minio_inbox_key }. PII rules: filename is operator-controlled; do NOT hash; file content is never echoed.- Structured INFO log per iteration boundary (
tenantId,objectsFound,durationMs).
Frontend Scope¶
N/A — backend only.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | N/A — backend only | — |
| Empty | N/A — backend only | — |
| Error | N/A — backend only | — |
| Offline / low network | N/A — backend only | — |
| Success | N/A — backend only | — |
Responsive Behavior¶
N/A.
Interactions¶
N/A.
Acceptance Criteria¶
AC-001: Cron iterates active tenants and emits ING_UPLOAD_RECEIVED per picked file
Given two active tenants A and B in `papillon_platform.tenant`
And a 1-KB text file dropped in `<tenantA>/inbox/foo.csv`
When `IngestionService.tick()` runs
Then exactly one `ING_UPLOAD_RECEIVED` audit row exists for tenant A with subject_type=INGESTION_IMPORT
And zero audit rows for tenant B
And the audit details contain `filename=foo.csv`, the streamed SHA-256, and the inbox key prefixed by tenant A's UUID
And the file is moved to `<tenantA>/processed/<yyyy-MM-dd>__foo.csv`.
AC-002: Local profile tick endpoint exists; non-local profiles do not expose it
Given the application running with `spring.profiles.active=local`
When `POST /internal/ingestion/tick` is called
Then it returns 204 and IngestionService.tick() is invoked
And the same request on `spring.profiles.active=staging` returns 404 (endpoint not registered).
AC-003: File over 5 MB is rejected at the hash-streaming step without ever being fully buffered
Given a 6-MB binary file in `<tenantA>/inbox/big.csv`
When IngestionService.tick() runs
Then the file is moved to `<tenantA>/rejected/<ts>__big.csv` with sidecar JSON `{"reason":"FICHIER_TROP_VOLUMINEUX"}`
And heap usage during the run stays under 256 MB (asserted by the test profile JFR snapshot).
Compliance Rules¶
- Architecture §7.4:
actor_type=SYSTEM,actor_id="ingestion-cron"is mandatory on every audit emission of this slice. - Critical rule #1 (tenant isolation,
docs/standards/critical-rules.md): MinIO key construction must derivetenantIdfrom the iteration variable only. - Architecture §7.6 A4: code review must reject any prefix derived from file content.
Standards & Conventions¶
docs/standards/critical-rules.md(rules #1 tenant isolation, #3 audit catalog).docs/standards/multi-tenant-model.md§ cron context.docs/standards/java-spring-guidelines.md§@Scheduled+@TransactionalEventListener.docs/standards/api-guidelines.md§ internal endpoints (profile-bound).
Testing Requirements¶
Unit Tests¶
IngestionInboxClientTest: key construction uses iteration-boundtenantIdonly; refuses keys not starting with<tenantId>/.IngestionAuditEventsTest:ING_UPLOAD_RECEIVEDpayload shape matches spec.
Integration Tests¶
IngestionSchedulerIT(TestContainers): two-tenant fixture, drop in A only → AC-001 assertions; verifyaudit_logrow count by tenant.IngestionLocalTickEndpointIT: profile-bound endpoint visibility per AC-002.IngestionLargeFileIT: AC-003 with a generated 6-MB random file.
What QA Will Validate¶
- Run smoke verification command from architecture §14.3 steps 1–4 (drop, tick, verify audit row, verify processed/ key). Step 5 (DB assertions on
ingestion_import) is N/A until ING-004.
Out of Scope¶
- CSV/XLSX parsing and row validation (ING-003).
ingestion_import/ingestion_import_rowtable writes, SHA-256 dedup against existing imports,PARSE_FAILED/DUPLICATE/COMMITTEDstatus transitions (ING-004).- Startup recovery for stuck
PICKED_UProws (ING-005). - Feature flag
feature.artci.phone-auth.grantedgating on prod profile (ING-005). - Operator UI for upload or history (V0.0.3+).
code_agenceresolution (V0.0.2+).
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360 / 768 / 1024 px (N/A — backend only)
- [ ] All 5 UI states implemented (N/A — backend only)
- [ ] French micro-copy matches design spec exactly (N/A — backend only)
- [ ] All acceptance criteria pass manually
- [ ] Unit + integration tests written and passing
- [ ] Tenant isolation verified (
tenant_idon every MinIO key + audit row; both V1 DB profiles tested — SHARED active, BYO smoke) - [ ] [CUSTOMER stories] payload + Slow-3G + resumability (N/A — backend only)
- [ ] Audit log entries present for every required event (
ING_UPLOAD_RECEIVEDper pickup; verified in IT) - [ ] No silent failures on network errors (MinIO transient errors logged at WARN and the iteration moves on; file stays in inbox for next tick — architecture §5.1 stage 1)