Skip to content

Latest commit

 

History

History
102 lines (76 loc) · 4.69 KB

File metadata and controls

102 lines (76 loc) · 4.69 KB

DECISIONS.md — Task Resolver

Key design choices and trade-offs.

Overview

TaskResolver is a stateless request/response service: each POST /resolve carries a full job payload, the server validates and sorts it in-process, and returns the result. Nothing is persisted.

Area Decision Rationale
Web stack Plug + Bandit Lightweight; no Phoenix for a single endpoint
Endpoint Stateless RPC (POST /resolve) Input → output transform; REST adds no value without persistence
Graph Kahn's sort on Map/MapSet/List Pure, heap-local; no :digraph or graph libs
Validation Ecto.Changeset + embedded_schema Structured validation without a database
Output Accept header negotiation Same result as JSON or bash script
Errors 400/406/415/422 taxonomy HTTP concerns separated from business-logic failures
Delivery Docker via docker compose up One command for app + API docs (see below)

Core logic & validation

Contract validation (TaskResolver.Core.JobValidator)

  • TaskSchema + JobSchema (embeds_many) model the payload via embedded_schema — no Ecto.Repo.
  • JobSchema pre-checks the raw tasks param (missing, not a list, non-objects) before cast_embed/3, so bad input becomes a changeset error, not a crash.
  • cast_embed(:tasks, required: true) also rejects empty task lists.
  • Duplicate names are checked cross-task after casting via get_field(:tasks).
  • :requires is cast with empty_values: [] so blank dependency names are rejected explicitly (Ecto otherwise silently drops them from arrays).
  • Errors are translated to a JSON-encodable map via traverse_errors/2.
  • Public API returns plain maps: {:ok, [task_map]} or {:error, {:invalid_job, errors_map}}.

Topological sort (TaskResolver.Core.Sorter)

  • Kahn's algorithm on Map/MapSet/List only — no :digraph.
  • Adjacency preserves input order so ties resolve deterministically (matches the challenge example: task-1, task-3, task-2, task-4).
  • Errors, checked in order: {:duplicate_task, name} → {:missing_dependency, name} → {:cyclic_dependency, names}.
  • Cycle reporting uses a bounded DFS on the stuck subgraph to return only tasks actually on the loop, not those merely blocked by it.

Core → HTTP error mapping

Source Shape HTTP
JobValidator {:error, {:invalid_job, errors_map}} 422
Sorter {:error, {:duplicate_task, name}} 422
Sorter {:error, {:missing_dependency, name}} 422
Sorter {:error, {:cyclic_dependency, [names]}} 422

400/406/415 are HTTP-layer only (malformed JSON, bad headers).

HTTP layer

Endpoint naming

  • POST /resolve — mirrors TaskResolver.resolve_tasks/1; spec allows flexible routing.
  • GET /health — liveness check for Docker; no JSON/Accept negotiation on GET.

Error handling approach

  • No Plug.ErrorHandler: it responds then re-raises, which breaks direct Plug.Test calls. Plug.Parsers is invoked manually inside try/rescue instead.
  • 415 for missing Content-Type: Plug.Parsers silently accepts requests with no Content-Type; a require_json_content_type plug runs first to close that gap.
  • 406 negotiation: missing Accept defaults to JSON; when present, the first supported media range in the header wins (client list order). Unsupported Accept → 406.
  • Response shapes: JSON omits requires; bash is #!/usr/bin/env bash + commands (BashFormatter). All errors share {error, message, details} JSON; ErrorMapper shapes core failures into 422 bodies.

Infrastructure & delivery

Docker — one setup, two files

There is one Docker workflow: docker compose up --build. The two files serve different roles in that same workflow — they are not alternatives:

File Role
Dockerfile Build recipe for the Elixir app image (elixir:1.20-alpine, mix run --no-halt, no release)
docker-compose.yml Orchestrator — starts the app (built from the Dockerfile) and Swagger UI together

docker-compose.yml defines two containers, not two setups:

  1. app — our service, built from Dockerfile, port 4000, healthcheck on /health
  2. swagger-ui — pre-built docs UI on port 8080, mounts ./docs/openapi.yaml (convenience for reviewers; not part of the Elixir app)

.dockerignore keeps the build context small; the app binds {0, 0, 0, 0} so port 4000 is reachable from the host.

Documentation

  • README.md — how to run (native + Docker) and curl examples
  • PLAN.md — phase checklist
  • DECISIONS.md — this file (the why)