Register syncs the next morning
01- Symptom:
- A transaction’s ingested_at is a day after its business date.
- Impact:
- Day-one sales understated.
- Solution:
- Extract by arrival time; the store-day waits as INCOMPLETE until totals match.
at ExtractRaw
An end-to-end retail pipeline built on synthetic data. Point-of-sale transactions in PostgreSQL and inventory in MongoDB are extracted by arrival time. Every store-day is reconciled against the store's close report, and daily sales and inventory KPIs are MERGEd into Snowflake. The local reference is verified by 12 passing tests; the AWS and Snowflake code is supplied but not yet run in the cloud.
Independent educational project with synthetic data. It is not Walmart’s real infrastructure, data or code, and is not sponsored by Walmart.
Hand-checked fixture · 2 stores · 1 late transaction · asserted by tests
| Track | Code written | Tested locally | Run in AWS |
|---|---|---|---|
Local reference pipeline Python stdlib + SQLite, every business rule | — | ||
AWS & Snowflake code Glue, Lambda, DynamoDB, Step Functions, Airflow, SNS | |||
Power BI report KPI views and DAX written, data export tested; the .pbix is not built and the DAX has not been run |
12 passing tests · 13 components · 1 planned
Important: the cloud code has not been run in an AWS or Snowflake account yet, and the repository is not public yet. Everything marked “tested locally” is proven by the Python + SQLite reference.
Switch views, press play to follow one batch, click a component to see its code.
Click a component for details and code. Dots: green tested locally · blue code supplied · amber planned.
AWS pipeline
Warehouse & BI
Orchestration & control plane
Reads rows whose ingested_at falls in the window, with the predicate pushed down to PostgreSQL, plus inventory from MongoDB and snapshots of the small dimensions. Writes Parquet partitioned by business_date and a manifest with row counts and file ETags.
PostgreSQL POS to Glue Extract: JDBC window. MongoDB to Glue Extract: connector. Glue Extract to S3 Raw: Parquet + manifest. S3 Raw to Validate Batch: size + ETag. Validate Batch to PySpark Reconcile: validated. PySpark Reconcile to S3 Curated & Publish: facts + status. S3 Curated & Publish to Snowflake: COPY + MERGE. Snowflake to Power BI Report: KPI views. Airflow to Step Functions: start + wait. Step Functions to DynamoDB Run Ledger: claim / mark. Step Functions to SNS Alerts: on failure.
Every design decision, chapter by chapter — start here before the interview questions.
ch. 011 / 10
A large retailer's registers send transactions all day. Most reach the central database within minutes. Some don't: a register that lost its connection syncs the next morning, and the point-of-sale (POS) system sometimes sends the same line twice.
Store managers still need daily sales they can trust. Those sales must be:
This is an independent educational project with synthetic data. It is not Walmart's real infrastructure, data or code, and it is not sponsored by Walmart.
| Txn | Store | Lines | Net | Arrives |
|---|---|---|---|---|
| T1 | 1001 East | 4 × $2.50 + 1 × $100.00 | $110.00 | same day |
| T2 | 1001 East | 2 × $4.00 (line resent once) | $8.00 | same day |
| T3 | 1002 West | $100.00 − $10.00 promotion | $90.00 | same day |
| T4 | 1002 West | return 1 × $2.50 | −$2.50 | same day |
| T5 | 1002 West | 3 × $4.00 | $12.00 | next morning |
Window 1: store 1001 publishes $118.00. Store 1002 has 2 of its 3 transactions, so it stays INCOMPLETE.
Window 2: T5 arrives and store 1002 publishes $99.50.
Final result: $217.50 net sales · 5 transactions · 10 units · $43.50 average basket · 2.00 units per transaction. Re-running either window changes nothing.
ch. 022 / 10
| Layer | Service | Why it is here |
|---|---|---|
| Sources | PostgreSQL (POS), MongoDB (inventory) | Two real source shapes: relational transactions and document snapshots |
| Ingestion | AWS Glue | JDBC and MongoDB connectors; the window predicate is pushed down to PostgreSQL |
| Raw storage | Amazon S3 | Parquet partitioned by business_date, and a manifest per run |
| Validation | AWS Lambda | Fast checks of the manifest against S3 before Spark starts |
| Run ledger | DynamoDB | A conditional put stops the same window from being processed twice |
| Transformation | Glue PySpark | Merge into curated history, reconcile store-days, aggregate facts |
| Warehouse | Snowflake | COPY INTO staging, then MERGE in one transaction |
| Orchestration | Airflow → Step Functions | Airflow decides when; Step Functions decides how |
| Alerting | Amazon SNS | Every failed run sends its run id, window and error |
Files move through S3: raw/ → curated/ → publish/. Step Functions never carries data, only the run_id and the window.
The run id is a hash of the window, so a retried window maps to the same raw prefix and the same ledger item.
The local reference (retail/) implements every rule in Python and SQLite and is covered by 12 tests. The Glue, Lambda, Snowflake, Step Functions, Airflow and CloudFormation code mirrors it but has not been run in a cloud account yet.
ch. 033 / 10
stores, departments, products, transactions, transaction_lines and store_close. Every fact table has an ingested_at column and an index on it, because extraction filters by arrival time.
transaction_lines deliberately has no primary key. Real POS feeds resend lines, so the pipeline must be able to deduplicate them.
inventory_snapshots holds one document per store, SKU and day, with on_hand and on_order. promotions holds SKU lists with start and end dates and a discount.
retail/generate.py is deterministic: the same seed always produces the same rows.
| Knob | Default | Example at scale |
|---|---|---|
--stores | 4 | 50 |
--days | 3 | 30 |
--txns (per store per day) | 40 | 400 |
It models a few retail patterns:
Measured locally: 20 stores × 7 days × 300 transactions produces 42,000 transactions and 144,102 lines, processed end to end in about 22 seconds.
python3 -m retail.generate --stores 50 --days 30 --txns 400 --out data/generated
python scripts/load_sources.py --data data/generated # into real PostgreSQL + MongoDBch. 044 / 10
Each run covers a half-open window [window_start, window_end) on ingested_at. The dimension tables (stores, departments, products) are small, so they are snapshotted in full on every run.
Why not filter by business date? Suppose a sale rung up on Monday reaches the database on Tuesday. If Monday was already extracted, that sale would be missed forever. Arrival windows pick up every row exactly once, and the transform maps it back to its business date.
The run is refused when a window does not start at the stored watermark, so no gap can silently skip rows.
raw/run_id=<sha256(window)[:16]>/
table=transactions/part-*.parquet
table=transaction_lines/…
manifest.json # rows, bytes and ETag (cloud) or SHA-256 (local) per tableThe validator checks:
It then claims the run in DynamoDB with attribute_not_exists(run_id) OR status = FAILED. A window that is in progress or has succeeded cannot be claimed again.
Two tests cover this: test_corrupted_raw_file_fails_validation and test_window_must_start_at_watermark.
ch. 055 / 10
| Rule | Implementation |
|---|---|
| Resends replace | Upsert on (txn_id, line_no); the latest ingested_at wins |
| Bad lines are rejected | Unknown SKU, zero quantity or non-positive price go to rejected_lines |
| A correction clears the rejection | An accepted resend deletes the earlier rejection |
| Returns | Negative quantity, tracked separately as return_cents |
| Promotions | Line discount_cents, tracked separately |
For every store-day this run touched, curated totals are compared with the store close report:
| Condition | Status | Effect |
|---|---|---|
| No close report yet | OPEN | Nothing published |
| Count and net match, no rejected lines | PUBLISHED | Store-day MERGEd into the facts |
| Anything else | INCOMPLETE | Previously published facts stay; the day waits |
One store's late register never blocks the other stores.
FACT_SALES_DAILY has one row per date × store × department, with units, gross, discount, returns and net.FACT_STORE_DAILY has one row per date × store, with transactions, units and net.FACT_INVENTORY_DAILY has one row per date × store × SKU, with on hand, on order and a stockout flag.In the cloud, the Glue job reads curated history only for the touched business_date partitions. It materialises the merge with localCheckpoint() before overwriting those partitions, because Spark cannot safely overwrite a path it is still lazily reading.
ch. 066 / 10
02_load_merge.sql runs per run id:
COPY INTO four temporary staging tables from @PUBLISH_STAGE/run_id=…/.BEGIN.FACT_SALES_DAILY rows for the republished store-days. A department that dropped to zero must disappear, which a plain MERGE would not do.MERGE FACT_STORE_DAILY, FACT_INVENTORY_DAILY, STORE_DAY_STATUS and PIPELINE_RUNS.COMMIT.If anything fails before COMMIT, everything rolls back and the facts keep their previous values. This is single-database atomicity, not distributed exactly-once.
The publisher only accepts a run id of 16 hex characters before substituting it into the SQL.
| View | Shows |
|---|---|
KPI_DAILY | Net sales, transactions, units, average basket, units per transaction |
SALES_BY_REGION | Net sales by region and store format |
SALES_BY_DEPARTMENT | Net, returns and discounts by department |
STOCKOUT_RATE | Share of store-SKU snapshots at zero |
STORE_DAYS_PENDING | Store-days waiting, with missing transactions and missing sales |
ch. 077 / 10
walmart_retail_daily runs @daily with catchup=True, so a backfill replays the days one by one. max_active_runs=1 keeps the windows in order. The DAG computes the window and the run id, starts Step Functions, and waits for it in reschedule mode.
ExtractRaw → ValidateBatch → TransformCurated → PublishSnowflake → MarkSucceeded
↘ ↘ ↘ ↘
MarkFailed → NotifyFailure (SNS) → FailedExtractRaw retries Glue service errors twice.Catch routes to MarkFailed, which records the run in DynamoDB, then to NotifyFailure, which publishes run id, window and error.A store-day waiting for late data is normal operation. It shows up in STORE_DAYS_PENDING and does not page anyone. A freshness alert for days that stay pending too long is the next step.
ch. 088 / 10
python3 -m unittest discover -s tests -t . -v # 12 tests
python3 -m retail --root .runtime demo # 4 daily windows, then KPIsExpected output: the early windows report some incomplete store-days. After the last window, every store-day is PUBLISHED.
| Test | Proves |
|---|---|
| late transaction holds store-day | INCOMPLETE until T5 lands, then $217.50 |
| resent line counted once | Store 1001 stays at 7 units and $118.00 |
| returns and discounts | Gross, discount, returns and net per department |
| replaying a window | ALREADY_PROCESSED; facts identical |
| window must start at watermark | No gaps |
| failure before commit | Facts and watermark roll back; a retry succeeds |
| corrupted raw file | Checksum and missing-table detection |
| rejected line | Holds the day until a corrected resend |
| stockout rate | 1 of 4 snapshots at zero = 0.2500 |
| concurrent writer | A second writer is refused |
| generator deterministic | Same seed, same rows |
| generated data reconciles | Every store-day publishes and totals equal the close reports |
Follow docs/STEPS.md in the repository, steps 3–9: Docker sources, CloudFormation, Glue jobs, Snowflake setup, the state machine and the Airflow DAG. Each step ends with a check you can see.
ch. 099 / 10
| Component | State |
|---|---|
| Generator, sources, extract, validation, reconciliation, MERGE, KPIs | Tested locally (12 tests) |
| PostgreSQL schema, Docker Compose, source loader | Written; schema mirrors the local tables |
| Glue extract and transform, validation Lambda, Snowflake publisher | Code supplied, not run in AWS |
| Snowflake setup, MERGE and views | Written, not run against Snowflake |
| Step Functions, Airflow, CloudFormation | Written, not deployed |
| Dashboard data export and preview | Tested locally (export: 9 tests; preview calculation rules: 22 tests) |
Power BI report (.pbix), DAX measures | KPI views and powerbi/measures.dax written; not built, DAX not run in Power BI Desktop |
| Repository | Local only, not public yet |
powerbi/README.md and pass its validation checklist.ch. 1010 / 10
The pipeline's job ends with published Snowflake tables. People only get value when they can read them. The report on top of those tables answers six kinds of questions, from six kinds of people:
| Who | Asks | Page |
|---|---|---|
| Store manager | Did my store hit its numbers, and is yesterday final? | Stores |
| Merchandiser | Which departments carry sales, and what do discounts and returns do to net? | Executive overview |
| Regional manager | Which region is ahead, and which stores drag it down? | Executive overview, then Stores |
| Supply chain | Which stores are running out of stock? | Inventory & data quality |
| Finance analyst | Can I trust this number before I put it in a deck? | Inventory & data quality |
| Data engineer | Is the pipeline delivering complete data? | Inventory & data quality |
The interactive preview at the end of this page lets you try each of these questions with real pipeline output.
| Visual | Reads | Measure |
|---|---|---|
| Net sales, sales by day, department and region | FACT_SALES_DAILY (date × store × department) | Net Sales |
| Transactions, average basket, units per transaction | FACT_STORE_DAILY (date × store) | Transactions, Average Basket, Units per Transaction |
| Stockout rate | FACT_INVENTORY_DAILY (date × store × SKU) | Stockout Rate |
| Data completeness, pending store-days | STORE_DAY_STATUS (date × store) | Data Completeness, Pending Store-Days |
Every measure is in powerbi/measures.dax and mirrors the Python and SQL logic that the 21 local tests cover, so the report reproduces the pipeline's numbers.
A store-day reaches the sales facts only when its totals match the store's own close report. Until then it is absent, not zero. The dashboard says so in three places:
In the demo data, after the last daily window 12 store-days are pending: for example one store has 148 of its 150 transactions in, and $11,420.60 of $11,600.10. After the catch-up window every store-day matches and net sales rise by exactly the close-report total of the store-days that were pending. A test asserts that equality.
Transactions, Average Basket and Units per Transaction return blank, because showing a number would count the same basket once per department.Discount Rate and Return Rate explain the gap between gross and net.powerbi/README.md: connect to Snowflake, load the published tables (never the raw or curated layers), mark a date table, create the relationships.powerbi/measures.dax.RETAIL.MART.KPI_DAILY, with the sum of the stores' close reports, and checks that a department selection blanks the basket measures.To see the target before building, generate the sample data used by the preview: python scripts/export_dashboard_data.py.
| Part | State |
|---|---|
Dashboard data export (retail/dashboard.py) | Tested locally: 9 tests, including reconciliation with the close reports |
| Preview on this page | Calculation rules tested against the exported data (22 tests); the DAX text it shows is checked against measures.dax |
| DAX measures | Written to match the tested logic; not yet run in Power BI Desktop |
Power BI file (.pbix) and scheduled refresh | Not built |
Project-based questions, easy to hard. Think first, then reveal the answer.
13 questions · think first, then reveal
at ExtractRaw
at TransformCurated
at TransformCurated
at TransformCurated
at ValidateBatch
at Pipeline start
at ValidateBatch
at PublishSnowflake
at ValidateBatch / publish
at ExtractRaw
Watch one execution, follow the records, then open the exact files.
Airflow starts one execution per daily window and waits. Every task failure routes to the same path: mark the run FAILED, then alert.
STEP 01 · AWS Glue
Read rows that arrived in the window from PostgreSQL and MongoDB; write Parquet and a manifest.
Failure handling — every failure is recorded and announced
Extract fails
Glue service errors retry twice; anything else fails the run.
→ MarkFailed → NotifyFailure
Manifest does not match S3
Stop before transforming; re-run the window to re-extract.
→ MarkFailed → NotifyFailure
Same window triggered twice
The DynamoDB conditional write refuses the second claim.
→ MarkFailed → NotifyFailure
Snowflake load fails
Transaction rolls back; facts keep their previous values.
→ MarkFailed → NotifyFailure
Store-days waiting for late data are not failures: they stay INCOMPLETE and appear in the STORE_DAYS_PENDING view.
walmart-retail-pipeline — Visual Studio Code
retail › pipeline.py — Curate → reconcile → MERGE publish, one transaction
A register sale becomes a row in FACT_SALES_DAILY (store × department × day) and FACT_STORE_DAILY.
1. Source
PostgreSQL POS
2. Raw S3
Parquet
3. Validation
Lambda
4. Curate
PySpark
5. Reconcile
Store close
6. Snowflake
MERGE
| txn_id | store | lines | net | arrives |
|---|---|---|---|---|
| T1 | 1001 East | 4 × $2.50 + 1 × $100.00 | $110.00 | same day |
| T2 | 1001 East | 2 × $4.00 (line resent once) | $8.00 | same day |
| T3 | 1002 West | 1 × $100.00 − $10.00 promo | $90.00 | same day |
| T4 | 1002 West | return 1 × $2.50 | −$2.50 | same day |
| T5 | 1002 West | 3 × $4.00 | $12.00 | next morning |
Why not publish $205.50 on day one?: Without reconciliation, window 1 would report store 1002 at $87.50 and the dashboard would be $12.00 short until someone noticed. Holding the store-day as INCOMPLETE keeps every published number equal to what the store actually rang up.
Each step ships with evidence — logs, query IDs, reconciled metrics — not just code.
A pipeline is only useful if people can read what it produces. This is the report that sits on top of the published Snowflake tables: three pages, one for each kind of question. Try it: pick a region or a department, click a store, switch to “Provisional” to see what the report shows while late registers are still syncing, and open “How this is built” under any visual to see its source table and DAX measure.
This is an interactive replica of the report, not a screenshot. It is drawn from real output of the pipeline’s local reference run (synthetic data: 12 stores, 14 days, 25,200 transactions) using the same calculation rules as powerbi/measures.dax, and those rules are covered by tests. The Power BI file itself (.pbix) has not been built yet, and the DAX has not been run in Power BI Desktop.
Loading report data…
Store manager
Did my store hit its numbers, and is yesterday final?
page: Stores
Merchandiser
Which departments carry sales, and what do discounts and returns do to net?
page: Executive overview
Regional manager
Which region is ahead, and which stores drag it down?
page: Executive overview, then Stores
Supply chain
Which stores are running out of stock?
page: Inventory & data quality
Finance analyst
Can I trust this number before I put it in a deck?
page: Inventory & data quality
Data engineer
Is the pipeline delivering complete data?
page: Inventory & data quality