A production data pipeline for tracking car prices
CarTracker started as a personal project — I was car shopping and wanted a data-driven way to spot a good deal before it disappeared. What began as a simple scraper grew into a production pipeline: continuous collection from Cars.com, a lakehouse built on Postgres, MinIO, and DuckDB, and a deal-score model that turns a long history of price observations into a single number. All of it runs on a single host, which is the constraint most of the interesting decisions here answer to. It is as much a learning project as a portfolio piece — every architectural decision was made, reconsidered, and sometimes replaced as the system grew into something that actually had to work.
Live stats
Every number above comes from the analytics marts, as of the timestamp shown. The marts refresh hourly, so these are recent rather than live. Counts describing the repository itself — services, DAGs, models, tests — are deliberately rounded on this page, because an exact figure in hand-written copy is a number nobody updates.
How data flows
The two forks are the whole design. The scraper writes the page and its pointer separately; processing writes current state and its event in one transaction, and those two writes begin two paths that never rejoin. The left path is a loop — current state decides what gets fetched next, in a Postgres view read transactionally by the same query that claims the work. The right path is a record, and nothing on it can block a fetch.
What lives where, and at what grain
| Layer | Physical home | Grain | Why it exists |
|---|---|---|---|
| Bronze | MinIO | One compressed HTML object per fetched results or detail page | Preserves exactly what the site returned, so parser changes are replayable |
| Operational | Postgres ops |
One current row per artifact, listing, VIN mapping, claim, or cooldown | Low-latency point lookups, transactions, leases, and conflict handling |
| Staging buffers | Postgres staging |
Append-only mutations and typed observations awaiting bulk export | Decouples small transactional writes from columnar object-store writes |
| Silver | MinIO (Parquet) | One typed observation per listing appearance, partitioned by source and date | Durable analytical history, cheap and scan-friendly |
| Mart | DuckDB, built by dbt | VIN-, listing-, hour-, cohort-, and benchmark-grain products | Turns history into dashboard-ready business meaning |
The division that makes the rest of it work: Postgres owns what the pipeline needs right now; Parquet owns what happened. Current state is small, mutable, and read by key. History is append-only, columnar, and read in scans. Serving both from one store is the design this pipeline started with, and replaced.
Production architecture
The platform runs as more than two dozen long-running services on a single Compose host, alongside a few profile-gated and one-shot ones. These are the parts worth explaining. Click any card for the reasoning behind it.
Fetches results and detail pages on a rotation across the tracked make/model pairs, and stores the compressed HTML without parsing it.
- Two-pass collection: broad market sweeps surface new listings and price changes; targeted detail fetches enrich each listing with specs, trim, dealer, and history
- Raw HTML is stored on every pass, so the source of truth is always replayable rather than limited to whatever the parser could extract at the time
- Requests are issued through an HTTP client whose TLS behavior matches a mainstream browser, so the site sees a client it recognises rather than a library default
- Refusals are governed by bounded retries and an adaptive per-listing cooldown that lengthens as blocks accumulate — the fetch path backs off rather than hammering
- A process-wide session cache avoids repeating the bootstrap handshake across concurrent workers
Performs a browser-assisted session bootstrap for pages that require one, and hands the resulting session back to the scraper over a small HTTP API.
- Some pages will not serve a plain HTTP client at all until a real browser has completed a session handshake — the sidecar is where that browser lives, so the scraper stays a simple HTTP client
- The live container is
trawl. An olderflaresolverrcontainer is still defined and still running, but has served no request since July; it is kept deliberately so health coverage never exempts a service believed unused - The wire protocol genuinely is FlareSolverr's v1 API, and the environment variable kept its original name — the implementation changed, the interface did not
- Isolating it means the memory cost of running a browser stays in one container that can be recycled on its own schedule, away from the fetch path
Turns stored HTML into structured observations, separating what the pipeline needs right now from what long-term analytics needs eventually.
- Every artifact is parsed from the original stored HTML — if the parser improves, history can be reprocessed from source without re-fetching a single page
- HOT tables in the
opsschema hold one current row per entity: listing, VIN mapping, claim, cooldown. Small, always current, read by key - The current row and its event are written in the same Postgres transaction. Staging event tables are the append-only buffer beside the HOT row, not a separate pass over it
- Idempotent by VIN, so the same listing can be processed more than once without producing duplicate records — which is what makes retries and replays safe
Moves the permanent record out of Postgres — exporting the staging event buffers to partitioned Parquet on object storage, then deleting the rows it exported.
- Postgres stays lean because it only holds current state and a short buffer; the archiver is what makes that true, by moving the permanent record out on a schedule
- It does not populate the HOT tables. Processing already wrote those. The archiver reads the event buffer, writes Parquet, and deletes what it exported — nothing else
- Parquet is hive-partitioned by source and date, which is the layout the downstream DuckDB and dbt queries actually scan
- Export and delete happen together, so a failed write to object storage loses nothing and the next run picks up where it stopped
- It also compacts silver partitions on a schedule, so readers never double-count a row that was written twice
Packs closed months of bronze HTML into immutable packs with a columnar index, and prunes the sources only after verifying the packed copy reads back identically.
- It is a separate service for one reason: packing a month is a long operation, and it must not be able to starve the archiver's short, frequent requests by sharing its worker pool
- Reading one artifact out of a pack is a ranged GET plus a single frame decompression, not a scan — the index makes the pack random-access
- A source object is deleted only after the packed replacement returns byte-identical content, verified against a hash held in the pack's sidecar index
- See Storage economics below for what packing was actually solving, which turned out not to be bytes
The analytical layer — 20+ dbt models across staging, intermediate, and mart layers, built by DuckDB reading Parquet straight from object storage.
- DuckDB reads Parquet from MinIO via the
httpfsextension, so there is no separate warehouse cluster to run or pay for — the lake is the warehouse - Models are layered by responsibility: staging cleans and types the raw events, intermediate builds reusable assets like price history and national benchmarks, marts answer specific questions
- The deal score combines MSRP discount, national price percentile, days on market, price-drop history, and inventory supply into a single 0–100 score with tier labels — written in SQL so the logic is readable and testable
- dbt tests run with every build, so transformation correctness is enforced the same way application correctness is
- What does not live here: the executable 403 backoff. dbt reads the same events for cohort, funnel, and block-rate analysis, but the decision about whether a listing can be claimed is a Postgres view — see Operational state and analytical history below
Sequences the whole pipeline — fetching, processing, archival, maintenance, and analytics — across services that have no direct knowledge of each other.
- Without an orchestration layer each service would need to know what runs next; Airflow is the only component that knows the full sequence, which is what keeps the services decoupled
- More than a dozen DAGs cover scrape rotation, artifact processing, staging flush, dbt builds, orphan claim expiry, queue cleanup, storage maintenance, and deploy coordination
- One DAG owns the scheduled analytics sequence.
hourly_analytics_refreshflushes staging events, flushes silver observations, then builds the models in order — which is why the component DAGs are deliberately manual-only rather than each running on its own timer - Thin DAGs, fat services: a DAG task carries scheduling and an HTTP call, and every piece of business logic it invokes is testable without Airflow running at all
- That boundary is what makes the orchestrator replaceable — if schedules were ever replaced by events, a consumer would call the same endpoints the DAG tasks call today
The control plane — authorization, configuration, claim coordination, deploy sequencing, and the page you are reading right now.
- Caddy calls
GET /auth/checkon every protected request, and Ops answers with the caller's role — so no other service in the stack contains any authorization code at all - Search configs, tracked make/model pairs, and user roles live here: one place to change pipeline behavior without editing service code
- Claim lifecycle management prevents two workers from fetching the same listing — Ops owns the coordination, and the workers just ask permission
- The deploy intent state machine signals every service to drain before its container is replaced, which is what makes a redeploy a sequence rather than a restart
The payoff — price history, deal scores, inventory coverage, and pipeline health, read from the DuckDB marts. Access-gated; the live stats above are its public slice.
- It is fast because of the layers underneath it: hive-partitioned Parquet, DuckDB's columnar engine, and pre-aggregated marts. The dashboard itself does almost no work, which is the intended outcome
- Data is recent, not live — the marts refresh hourly, which is the right trade for analytical questions that do not need to be real-time, and the page says so rather than implying otherwise
- It surfaces pipeline health beside market data: block rates, processing lag, stale listings. Operational problems become something you can see rather than something you go looking for in logs
- It is gated by the same authorization layer as everything else — a consumer of the architecture, not a special case in it
Holds current operational state and the short-lived event buffers beside it — and, deliberately, nothing else.
- Three schemas with three jobs: configuration, current operational state, and short-lived append-only buffers awaiting export
- Because only the current row per entity stays, the table the scrape queue reads stays small and deletable no matter how much history the project accumulates
- Every schema change is a versioned migration applied automatically on deploy — 40+ of them, including expand/contract sequences that let readers and writers change in separate releases
- What it is not: the analytics store. An early version kept an append-only price log here, and that is the decision the architecture below exists to correct
The storage backbone — replayable bronze HTML and the permanent Parquet history, in a format built for the queries that read it.
- Two tiers with different retention because the data has different value at each: compressed bronze HTML, packed and pruned on a monthly cycle, and Parquet observations kept indefinitely
- The columnar format is why DuckDB can scan the full observation history quickly; a row-oriented store cannot match it for this access pattern, whatever its size
- S3-compatible on purpose: MinIO is right-sized for a single-server deployment, but the interface is the same as cloud object storage, so the stack ports without a code change
- Bronze objects are written as independently decompressible frames — see Storage economics below for why that detail is load-bearing
The front door — TLS, Google authentication, and role enforcement, so every service behind it can be written as though authorization were somebody else's problem.
- Authentication and authorization are deliberately separate, and neither needs a dedicated identity service: oauth2-proxy proves who you are, and a forward-auth call to Ops decides what that identity may do
- Four roles, narrowing from admin to viewer. Someone who authenticates but holds no role is sent to a request-access page rather than a wall
- Emails are stored only as a salted SHA-256 hash, never as plaintext — the authorization table cannot leak an address it does not hold
- Enforcement happens at the edge, which is why no application service in this stack contains authorization code to review, or to get wrong
- Automatic TLS via Let's Encrypt: production-grade even though the audience is small
Metrics, logs, three-state service health, and alerting — designed around the failure this project actually had, which was silence rather than errors.
- A pipeline this distributed fails quietly. A blocked fetch path, a processing backlog, or a flush that stopped flushing throws no error you would notice until the data goes stale
- Prometheus scrapes every layer: DAG outcomes, database connections, object-store throughput, host resources, and custom gauges that expose mart-layer data health as metrics directly
- Loki aggregates logs via Promtail from the services deliberately admitted to it — each under a written ingestion policy deciding what is retained and at what severity, and every excluded service under a written reason — searchable across the whole stack from one query
- Health is three-state against a checked-in expected service set, so a service that is absent reads as failure instead of vanishing from the query — and "running, but no healthcheck configured" reads as its own unattractive value rather than as healthy
- More than twenty alert rules are provisioned as code and route to Telegram. Several fire on missing success rather than on visible errors — no successful bootstrap in a window while failures accumulate, no successful fetch while attempts continue
Platform evolution
Everything above is running in production today. The work below is proven and evidenced, and none of it is in the path of anything a visitor to this site sees. It is listed separately because a portfolio that blurs the two is telling you about a roadmap while implying it is a system.
- Iceberg via Lakekeeper, exercised through Spark. Tables register and read, and dbt-Spark parity work runs against them. The dashboard still reads DuckDB, and will until the migration's own gates close.
- MLflow experiment provenance — tracking for the modelling work, not a serving path.
- Adaptive-refresh and backtesting foundations — the groundwork for deciding what to re-fetch and when, based on how a listing has actually behaved.
The short version: the dashboard reads DuckDB. The Iceberg work is a migration track with its own gates, not a shipped capability.
Recent work
Work here is planned and archived in the open: every change is a plan document with a stated trigger and an explicit gate, ordered in one table. Two rules make that record worth reading — a merged change is not a finished one while its production gate is outstanding, and a plan reaches the archive only when its evidence does.
Planned next
- The top of the ordered build order is whatever comes next.
Recently completed
- Newest first, each row dated by the evidence that closed it.
For the long-form account there is a weekly recap, one page per week written against that week's commits — a point-in-time record, never revised to match a later truth.
Decisions worth explaining
Five choices that changed the shape of the system. Two of them started as something that broke.
Deploying without breaking what is already running
Restarting a container mid-pipeline means lost work, orphaned claims, and partial writes that are awkward to recover from. So a redeploy is a sequence of claims that must become true: request scoped coordination, drain until admitted work is gone, recreate only the requested services, wait for each to reach its real health state, validate, and only then release the gate. The two failure branches differ on purpose — if validation fails before any container changed, coordination is released, because the pipeline should not stay paused for a change that never happened. If a container was already replaced and a later step fails, coordination stays held, because a mixed-version fleet is a worse place to resume background work than a visible pause.
How it is tested
More than 3,000 tests run in CI. The count is the least interesting fact about them — the variety of boundaries is the point, because many of the hardest bugs here live between files or engines rather than inside a function.
Pure behavior
Parser rules, retry and backoff calculations, pack formats, metrics, state transitions, failure predicates. No database, no Docker, run first.
Configuration contracts
Compose healthcheck coverage, the expected service set, Prometheus jobs, Grafana selectors, log-shipping policy, deployment timeout relationships, planning-document invariants.
Real service integration
Migrated Postgres, object storage, DuckDB extensions, HTTP routers, Airflow DAG loading, archiver operations. No mocks where real behavior is what is in question.
Cross-engine equivalence
The same production-shaped lake fixture is evaluated by dbt and by independently written selector SQL, so an optimization cannot quietly change business meaning and pass as a speedup.
The fixture underneath that fourth layer is built with its arrow reversed. The usual approach takes some production rows and hopes they exercise the code. Here the branches come first: each dbt branch or guard worth protecting is named as a coverage selector, and the selector is a SQL query that goes and finds production entities exhibiting that behavior. There are more than twenty — gaps-and-islands state runs, price-drop and price-increase lags, source-priority ties where a detail row outranks a search-results row, sparse benchmark groups that must not silently disappear, each 403 cooldown bucket boundary. The selected entities are then closed over VINs, listings, and artifacts, so rows arrive with everything they need to join. What the snapshot asserts is coverage itself: a named behavior with no representative is a reported shortfall rather than a branch nobody noticed was untested.
A unit test for the health collector cannot prove every Compose service has a healthcheck,
and a dbt model test cannot prove the archiver's separately maintained selector returns the
same cohort. Those need repository-level assertions, which is why configuration and
equivalence tests are first-class here rather than an afterthought. CI builds a miniature
production data plane for every run: real migrations against a real Postgres, the same
pinned adapters and extensions the runtime uses, a seeded lake fixture, a real dbt build,
and then the integration suites against it. Schema changes are versioned migrations
reviewed as pull requests — ALTER TABLE in production without a migration
history is its own category of wrong, and this project does not allow it.