(each step follows 1. Write failing TUnit tests → 2. Implementation)
-
Tests – none (scaffolding only)
-
Implementation
-
Create solution:
dotnet new aspire -n BgChallenge # generates AppHost + defaults -
Restructure folders/projects:
/aspire/ ├─ AppHost/ (generated host stays here) └─ ServiceDefaults/ (generated) /src/ ├─ Domain/ (new class-lib: dotnet new classlib -n Domain) ├─ Api/ (rename generated Service1 → Api) └─ Worker/ (new worker-svc: dotnet new worker -n Worker) /tests/ ├─ Unit/ (dotnet new tunit -n Unit) └─ Integration/ (dotnet new tunit -n Integration) -
Update solution refs:
dotnet sln BgChallenge.sln add src/Domain/Domain.csproj dotnet sln BgChallenge.sln add src/Api/Api.csproj dotnet sln BgChallenge.sln add src/Worker/Worker.csproj dotnet sln BgChallenge.sln add tests/Unit/Unit.csproj dotnet sln BgChallenge.sln add tests/Integration/Integration.csproj
-
Add
Directory.Build.propsfor common nullable/implicit-usings. -
Ensure
dotnet buildsucceeds.
-
-
Tests
- Unit (
Job):CanParseFromJson,ValidatesJobId,ValidatesImgUrl - Unit (
Job):ExtractsResultFileFromUrl,HandlesUrlWithoutQueryString,HandlesComplexQueryParams - Unit (
Job):HasDefaultUnknownStatus,TracksJobStatus,DetectsDuplicatePath
- Unit (
-
Implementation
-
Domain –
Jobentity (rich domain model) withJobId(Guid),Type,ImgUrl,Status,ResultFileproperties. -
Domain –
Job.ResultFileproperty that automatically extracts path fromImgUrl(strips query string at?). -
Domain –
JobStatusenum with status tracking (Unknown,Queued,Processing,Completed,Failed,Canceled). -
Api – Add EF Core with Aspire PostgreSQL integration, create
AppDbContextwithJobsDbSet. -
EF migration for
jobstable withUNIQUE(job_id, result_file)constraint. -
CRITICAL:
Job.ResultFilemust internally handle URL parsing to extract clean path (e.g.,results_2.pngfromhttps://...results_2.png?X-Amz-Expires=...) to avoid signature mismatches. -
Development: Auto-migration on startup for seamless F5 developer experience.
-
Reference Job structure:
{ "jobId": "0197718c-2355-725e-a8e3-7f8dd78c7ff0", "type": "tryon", "imgUrl": "https://example.com/path/results_2.png?X-Amz-Expires=..." }
-
-
Tests (
Enqueue)CanAcceptIsIdempotent- Unit (
Job):CanExtractResultFile,DetectsDuplicatePath
-
Implementation
- Api –
EnqueueRequestrecord; filtersImageFileGuard. - Use existing
Jobentity with status tracking. - Return 409 Conflict on duplicate; insert row then
NOTIFY jobs_channel, id.
- Api –
-
Tests (
DownloadWorker)CanCompleteOnNotifyFailsOnInvalidHeadRescuesOrphanedProcessingJob← newLeavesLiveProcessingJobAlone← new
-
Implementation
-
Schema
- Add nullable
LockKey bigintcolumn toJobsvia EF migration. - Keep PascalCase in the model (
LockKey).
- Add nullable
-
Worker startup
-
Generate
workerSaltonce (random 32‑bit hex). -
Rescue pass
SELECT "JobId","LockKey" FROM "Jobs" WHERE "Status" = 'Processing';
For each row run
pg_try_advisory_lock(:LockKey)
true⇒ job is orphaned →UPDATEback toQueued, thenpg_advisory_unlock
false⇒ another worker owns it → leave untouched
-
-
Claim query (inside one transaction)
WITH next AS ( SELECT "JobId","ResultFile" FROM "Jobs" WHERE "Status" = 'Queued' ORDER BY "CreatedAt" LIMIT 1 FOR UPDATE SKIP LOCKED ) UPDATE "Jobs" SET "Status" = 'Processing', "LockKey" = hashtextextended( (next."JobId"::text || next."ResultFile" || @salt), 0 )::bigint, "UpdatedAt" = extract(epoch from now())::bigint FROM next WHERE "Jobs"."JobId" = next."JobId" RETURNING *;
- Commit, then on the same Npgsql connection
SELECT pg_advisory_lock(:LockKey);
- Commit, then on the same Npgsql connection
-
Processing loop
- Validate via
HEAD, stream to S3, retry × 3 (1s → 4s → 8s).
- Validate via
-
Finish job
BEGIN; UPDATE "Jobs" SET "Status" = 'Completed', "UpdatedAt" = extract(epoch from now())::bigint, "LockKey" = NULL; SELECT pg_advisory_unlock(:LockKey); COMMIT;
-
Crash scenario
Connection drops → advisory lock released,LockKeystays in row → next worker rescues with the startup pass. -
LISTEN / NOTIFY
- Worker keeps its connection open with
LISTEN jobs_channel. - On
NOTIFYrun the rescue pass first, then the claim query.
- Worker keeps its connection open with
-
-
Tests (
List)Returns304WhenUnchangedReturnsItemsWhenUpdated
-
Implementation
- Use Simon Cropp Delta to compute ETag (max
updated_at). - Expose
startedAt,endedAt,durationMs.
- Use Simon Cropp Delta to compute ETag (max
-
Tests (
Cancel)CanCancelQueuedItemIsIdempotentOnNonQueued
-
Implementation
- Endpoint flips status to Canceled, sets
canceled_at; worker skips non-Queued.
- Endpoint flips status to Canceled, sets
-
Tests (
Metrics)MetricsPopulated
-
Implementation
- Populate timing columns; map to list response.
- Domain remains pure – all business logic is in
/src/Domain. - Memory footprint – constant via streaming.
- Scale path – swap
IJobQueuefor SQS/Rabbit when sustained load > 500 msg/s.