Day 30: Real-world scenarios, part 2 & final assessment
On this page
- A. Lesson overview
- B. Theory explained clearly
- C. Dataset and starting state
- D. Worked examples
- E. Guided practical lab — The SCD Type 2 load procedure
- F. Final project — Customer sales mart
- G. Reference solution and explanations
- H. Knowledge check
- I. Troubleshooting and cleanup
- J. Completion checklist, recap and references
By the end of this lesson you can
- Design a staging-to-target load that validates, de-duplicates, logs, rejects with reasons, and can be re-run safely.
- Implement a Type 2 slowly changing dimension with Type 1 attributes, half-open validity intervals and surrogate keys.
- Look up the dimension version that applied on a given date (point-in-time join).
- Monitor a running system with DMVs: current requests, blocking chains, waits and expensive cached queries.
- Deliver an end-to-end solution — dimension, fact, report, monitoring and checks — and assess it against a rubric.
A. Lesson overview
| Week | 4 — Performance and expert topics (capstone) |
| Level | Advanced |
| Estimated time | 3 h essential (lesson and lab) + about 3–4 h final project; 1 h optional stretch |
| Prerequisites | The whole course; especially Days 12, 15, 16, 20, 21, 22–24 and 29 |
| Syllabus topic | ETL from staging, slowly changing dimension (Type 2), DMV-based monitoring, final project with rubric |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — A dependable ETL load | 15 min |
| Part 2 — Slowly changing dimensions | 20 min |
| Part 3 — Point-in-time lookups | 10 min |
| Part 4 — Monitoring with DMVs | 20 min |
| Worked examples | 30 min |
| Guided lab (SCD Type 2 load procedure) | 45 min |
| Knowledge check | 10 min |
| Final project (section F) | 3–4 h |
| Stretch challenges (optional) | 1 h |
Business scenario. The retailer is building a small sales mart. The CRM sends a daily feed of customer changes. Analysts need to report sales by the customer segment at the time of the sale as well as by today’s segment; the DBA needs to see what the load is doing and whether it blocks anyone. This is your final project.
What this course has and hasn’t done. Thirty days of structured practice give you a solid, broad working knowledge of T-SQL and a portfolio of exercises. Expertise comes from applying these techniques to real systems over time — keep practising on your own data.
B. Theory explained clearly
Part 1 — A dependable load
| Stage | What happens | Day |
|---|---|---|
| Land | Raw data lands unchanged in staging (no constraints) | 15 |
| Validate | Each row is checked; failures are kept with a reason, not silently dropped | 21 |
| De-duplicate | One row per business key per batch — the latest by change time, with a tie-breaker | 5, 15 |
| Transform and load | Set-based statements in one transaction | 16 |
| Log | A run log row: start, end, status, counts | 20 |
| Reconcile | Counts in = accepted + rejected + superseded | 21, 29 |
Two qualities matter most:
- Idempotency — running the same batch twice leaves the target exactly as after the first run. Change detection (“only update if a value differs”) is what makes this work.
- Atomicity — either the whole batch applies or none of it does (
XACT_ABORT ON,TRY…CATCH, one transaction). The run log row is written before the transaction, so a failed run is still recorded.
Part 2 — Slowly changing dimensions
A dimension describes the “who/what/where” of facts (customer, product). When a descriptive attribute changes, you choose per attribute how history is kept:
| Type | On change | Example here |
|---|---|---|
| 0 | Never change | Date joined |
| 1 | Overwrite — no history (corrections, attributes nobody reports history on) | Name, email, phone |
| 2 | Close the current version, insert a new one — full history | City, country, segment |
| 3 | Keep “previous value” in an extra column | Rarely used |
A Type 2 dimension has:
- a surrogate key (
CustomerKey,IDENTITY) — one per version; facts point to it; - the business key (
CustomerID) — the same for all versions of a customer; - a validity interval, here half-open:
ValidFrom <= date < ValidTo, withValidTo = '9999-12-31'for the current version (as temporal tables do, Day 12) — consecutive versions meet exactly, with no gaps or overlaps; - an
IsCurrentflag, enforced by a filtered unique index: one current row per customer.
Order of operations for a batch dated D: apply Type 1 changes to all versions; close changed current versions (ValidTo = D, IsCurrent = 0), capturing which customers changed (OUTPUT … INTO); insert their new versions (ValidFrom = D); insert brand-new customers. MERGE can do parts of this, but with the caveats from Day 15 — separate statements in one transaction are easier to read and test.
Part 3 — Point-in-time lookups
To attach the right version to a fact, join on the business key and the date:
LEFT JOIN Lab.DimCustomer AS d
ON d.CustomerID = o.CustomerID
AND o.OrderDate >= d.ValidFrom
AND o.OrderDate < d.ValidTo
With non-overlapping half-open intervals, each order matches exactly one version. Use LEFT JOIN and map a missing match to an “unknown” key (−1) rather than losing the fact row; then check that there are none.
Reports can then show either as it was (join the fact’s CustomerKey) or as it is now (join on CustomerID to the current version) — often a deliberate business choice.
Part 4 — Monitoring with DMVs
Dynamic management views expose the engine’s current state. The essentials:
| Question | DMVs |
|---|---|
| What is running right now? | sys.dm_exec_requests (+ sys.dm_exec_sql_text(sql_handle)) |
| Who is connected, with open transactions? | sys.dm_exec_sessions (open_transaction_count) |
| Who is blocking whom? | sys.dm_exec_requests.blocking_session_id, sys.dm_os_waiting_tasks |
| What are we waiting on overall? | sys.dm_os_wait_stats (cumulative since restart) |
| Which cached queries cost most? | sys.dm_exec_query_stats (+ sys.dm_exec_query_plan) |
| Index use and missing indexes | sys.dm_db_index_usage_stats, missing-index DMVs (Day 23) |
| History across restarts | Query Store (Days 22, 24) |
Server-scoped DMVs need VIEW SERVER STATE (or, in SQL Server 2022+, the narrower VIEW SERVER PERFORMANCE STATE); database-scoped ones need VIEW DATABASE STATE. Grant these to monitoring logins rather than sysadmin. DMV data is volatile: plan-cache statistics disappear when plans are evicted or the server restarts.
C. Dataset and starting state
| Dataset | Shared synthetic dataset plus the Day 30 objects |
| Database | RetailLab |
| Read from | Sales.SalesOrder, Sales.SalesOrderLine, Sales.Customer, Lab.CustomerFeed |
| Changed | Lab.DimCustomer, Lab.EtlRunLog, Lab.EtlReject, Lab.CustomerMatchReview, Lab.FactSales (project) |
| Permissions | CREATE TABLE, CREATE PROCEDURE, CREATE VIEW, ALTER on Lab; VIEW SERVER STATE (or VIEW SERVER PERFORMANCE STATE) for the monitoring parts |
| Scripts | scripts/lessons/day30_final_project_setup.sql (start state), scripts/lessons/day30_validate_final_project.sql (checks) |
Setup. Run day30_final_project_setup.sql (download it). It is destructive for the Day 30 Lab objects only and can be re-run to start again.
| Object | Contents |
|---|---|
Lab.DimCustomer |
42 rows: one current version per customer (40), plus two seeded history versions: customer 13 was Consumer until 2025-07-01 (then Business); customer 18 was Consumer until 2025-04-01 (then VIP). Type 1: FirstName, LastName, Email, Phone; Type 2: City, Country, CustomerSegment |
Lab.CustomerFeed |
11 CRM rows for batch 2026-01-15 — see below |
Lab.EtlRunLog, Lab.EtlReject, Lab.CustomerMatchReview |
Empty |
The feed, row by row:
| FeedRowID | CustomerID | What it contains |
|---|---|---|
| 1 | 4 | Moved: Leeds → York (Type 2) |
| 2, 3 | 7 | Two changes on the same day: Business at 09:00, then VIP at 16:00 — only the latest applies (Type 2) |
| 4 | 2 | New email ben.carter@example.org (Type 1) |
| 5 | 9 | No change |
| 6, 7 | 41, 42 | New customers (Daisy Holt, Kofi Mensah) |
| 8 | 44 | New customer Emma Wilson — same phone, name and city as existing customer 5 |
| 9 | NULL | No customer ID — reject |
| 10 | 43 | Segment Gold — reject |
| 11 | 18 | New email (Type 1) and moved Milan → Rome (Type 2) |
Validation. The script ends with this query. Expected: 42, 40, 2, 11, 0.
SELECT (SELECT COUNT(*) FROM Lab.DimCustomer) AS DimRows,
(SELECT COUNT(*) FROM Lab.DimCustomer WHERE IsCurrent = 1) AS CurrentRows,
(SELECT COUNT(*) FROM Lab.DimCustomer WHERE IsCurrent = 0) AS HistoryRows,
(SELECT COUNT(*) FROM Lab.CustomerFeed) AS FeedRows,
(SELECT COUNT(*) FROM Lab.EtlRunLog) AS Runs;
D. Worked examples
Worked example 1 — Classify the feed before loading anything
WITH Classified AS
(
SELECT f.FeedRowID, f.CustomerID, f.CustomerSegment, f.ChangedAt,
CASE WHEN f.CustomerID IS NULL THEN N'Missing CustomerID'
WHEN f.CustomerSegment IS NULL
OR f.CustomerSegment NOT IN ('Consumer', 'Business', 'VIP') THEN N'Invalid segment'
WHEN NULLIF(TRIM(f.FirstName), N'') IS NULL
OR NULLIF(TRIM(f.LastName), N'') IS NULL THEN N'Missing name'
WHEN f.City IS NULL OR f.Country IS NULL THEN N'Missing city or country'
END AS RejectReason
FROM Lab.CustomerFeed AS f
)
SELECT FeedRowID, CustomerID, RejectReason,
CASE WHEN RejectReason IS NOT NULL THEN 'Rejected'
WHEN ROW_NUMBER() OVER (PARTITION BY CustomerID, CASE WHEN RejectReason IS NULL THEN 0 ELSE 1 END
ORDER BY ChangedAt DESC, FeedRowID DESC) > 1 THEN 'Superseded'
ELSE 'Accepted' END AS Outcome
FROM Classified
ORDER BY FeedRowID;
Expected result: 11 rows — rows 1, 3–8 and 11 Accepted (8); row 2 Superseded; row 9 Rejected — Missing CustomerID; row 10 Rejected — Invalid segment. 8 + 1 + 2 = 11: every row accounted for. Rejections are decided first, and only valid rows compete for “latest” — a design choice worth writing down.
Worked example 2 — A Type 2 change by hand (in a transaction you roll back)
BEGIN TRANSACTION;
DECLARE @Changed TABLE (CustomerID int PRIMARY KEY);
UPDATE Lab.DimCustomer
SET ValidTo = '2026-01-15', IsCurrent = 0
OUTPUT inserted.CustomerID INTO @Changed
WHERE CustomerID = 4 AND IsCurrent = 1 AND City <> N'York';
INSERT INTO Lab.DimCustomer (CustomerID, FirstName, LastName, Email, Phone, City, Country, CustomerSegment, ValidFrom)
SELECT d.CustomerID, d.FirstName, d.LastName, d.Email, d.Phone, N'York', d.Country, d.CustomerSegment, '2026-01-15'
FROM Lab.DimCustomer AS d
JOIN @Changed AS c ON c.CustomerID = d.CustomerID
WHERE d.ValidTo = '2026-01-15';
SELECT CustomerKey, CustomerID, City, ValidFrom, ValidTo, IsCurrent
FROM Lab.DimCustomer WHERE CustomerID = 4 ORDER BY ValidFrom;
ROLLBACK TRANSACTION;
Expected result: two rows for customer 4: Leeds 2021-11-05 2026-01-15 0 and York 2026-01-15 9999-12-31 1 (the new CustomerKey is 43 — the next identity value — but it is rolled back, and identity values aren’t reused, so your later keys will start higher). The old version’s ValidTo equals the new version’s ValidFrom: no gap, no overlap. Running the UPDATE again would change nothing — City <> N'York' is false for the new current row.
Worked example 3 — Which version applied on a date?
SELECT o.OrderID, o.OrderDate, d.CustomerKey, d.CustomerSegment, d.ValidFrom, d.ValidTo
FROM Sales.SalesOrder AS o
JOIN Lab.DimCustomer AS d
ON d.CustomerID = o.CustomerID
AND o.OrderDate >= d.ValidFrom AND o.OrderDate < d.ValidTo
WHERE o.CustomerID = 18 AND o.OrderDate >= '2025-03-01' AND o.OrderDate < '2025-06-01'
ORDER BY o.OrderDate, o.OrderID;
Expected result: 4 rows — 1207 2025-03-14 19 Consumer 2023-03-03 2025-04-01, 1211 2025-03-19 19 Consumer …, 1243 2025-05-20 20 VIP 2025-04-01 9999-12-31, 1249 2025-05-27 20 VIP …. Orders before 1 April use the Consumer version (key 19), later ones the VIP version (key 20). The keys come from the setup’s load order (customers 1–12 get keys 1–12, customer 13’s two versions 13–14, and so on) and are the same every time you run the setup script. Run it for the whole of 2025 to see that every order matches exactly one version.
Worked example 4 — Watching a blocked session
Open three query windows on RetailLab.
| Step | Window | Run |
|---|---|---|
| 1 | A | BEGIN TRANSACTION; UPDATE Lab.DimCustomer SET Phone = Phone WHERE CustomerID = 9; — leave it open |
| 2 | B | SELECT * FROM Lab.DimCustomer WHERE CustomerID = 9; — it keeps running |
| 3 | C | the monitoring query below |
| 4 | A | ROLLBACK TRANSACTION; — window B completes |
SELECT r.session_id, r.blocking_session_id, r.status, r.command,
r.wait_type, r.wait_time AS WaitMs, r.wait_resource,
DB_NAME(r.database_id) AS DatabaseName,
SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(t.text)
ELSE r.statement_end_offset END - r.statement_start_offset) / 2 + 1) AS RunningStatement,
s.login_name, s.host_name, s.program_name
FROM sys.dm_exec_requests AS r
JOIN sys.dm_exec_sessions AS s ON s.session_id = r.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.blocking_session_id <> 0;
-- The blocker may be idle (not running anything) — find it through sessions
SELECT s.session_id, s.status, s.open_transaction_count, s.last_request_end_time, s.login_name
FROM sys.dm_exec_sessions AS s
WHERE s.session_id IN (SELECT blocking_session_id FROM sys.dm_exec_requests WHERE blocking_session_id <> 0);
Expected result: while window B waits — one row: B’s session ID, blocking_session_id = A’s session ID, wait_type LCK_M_S (waiting for a shared lock), the SELECT text. The second query shows A as sleeping with open_transaction_count 1 — the classic “someone left a transaction open” picture. Session IDs are your own. (If you enabled read committed snapshot on Day 16 and left it on, B won’t block — it reads the last committed version.)
E. Guided practical lab — The SCD Type 2 load procedure
You’ll build and run Lab.usp_LoadDimCustomer, which part 1 of the final project then relies on.
Step 1 — Create the procedure
- Do: Read it section by section — every section maps to Part 1 and Part 2 of the theory.
- Run:
USE RetailLab; GO CREATE OR ALTER PROCEDURE Lab.usp_LoadDimCustomer @BatchDate date AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; DECLARE @RunID int, @Read int, @Rejected int, @Superseded int, @Inserted int = 0, @Expired int = 0, @Type1 int = 0, @Err nvarchar(2048); DECLARE @Changed TABLE (CustomerID int PRIMARY KEY); DECLARE @New TABLE (CustomerID int PRIMARY KEY); -- Log the start OUTSIDE the transaction, so a failed run is still recorded INSERT INTO Lab.EtlRunLog (ProcessName, BatchDate) VALUES (N'usp_LoadDimCustomer', @BatchDate); SET @RunID = SCOPE_IDENTITY(); BEGIN TRY IF @BatchDate IS NULL THROW 50700, N'@BatchDate is required.', 1; IF EXISTS (SELECT 1 FROM Lab.DimCustomer WHERE IsCurrent = 1 AND ValidFrom > @BatchDate) BEGIN SET @Err = FORMATMESSAGE(N'Batch date %s is earlier than an existing version.', CONVERT(nvarchar(10), @BatchDate, 23)); THROW 50701, @Err, 1; END; -- 1. Validate DROP TABLE IF EXISTS #Feed; SELECT f.*, CASE WHEN f.CustomerID IS NULL THEN N'Missing CustomerID' WHEN f.CustomerSegment IS NULL OR f.CustomerSegment NOT IN ('Consumer', 'Business', 'VIP') THEN N'Invalid segment' WHEN NULLIF(TRIM(f.FirstName), N'') IS NULL OR NULLIF(TRIM(f.LastName), N'') IS NULL THEN N'Missing name' WHEN f.City IS NULL OR f.Country IS NULL THEN N'Missing city or country' END AS RejectReason INTO #Feed FROM Lab.CustomerFeed AS f; -- 2. De-duplicate: latest valid row per customer DROP TABLE IF EXISTS #Valid; SELECT CustomerID, TRIM(FirstName) AS FirstName, TRIM(LastName) AS LastName, NULLIF(LOWER(TRIM(Email)), N'') AS Email, Phone, City, Country, CustomerSegment INTO #Valid FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY CustomerID ORDER BY ChangedAt DESC, FeedRowID DESC) AS rn FROM #Feed WHERE RejectReason IS NULL) AS x WHERE rn = 1; SELECT @Read = COUNT(*), @Rejected = COUNT(RejectReason) FROM #Feed; SET @Superseded = @Read - @Rejected - (SELECT COUNT(*) FROM #Valid); BEGIN TRANSACTION; INSERT INTO Lab.EtlReject (RunID, FeedRowID, Reason) SELECT @RunID, FeedRowID, RejectReason FROM #Feed WHERE RejectReason IS NOT NULL; -- 3. Type 1: overwrite on every version, only where something differs UPDATE d SET FirstName = v.FirstName, LastName = v.LastName, Email = v.Email, Phone = v.Phone, UpdatedRunID = @RunID FROM Lab.DimCustomer AS d JOIN #Valid AS v ON v.CustomerID = d.CustomerID WHERE d.FirstName IS DISTINCT FROM v.FirstName OR d.LastName IS DISTINCT FROM v.LastName OR d.Email IS DISTINCT FROM v.Email OR d.Phone IS DISTINCT FROM v.Phone; SET @Type1 = @@ROWCOUNT; -- 4. Type 2: close changed current versions… UPDATE d SET ValidTo = @BatchDate, IsCurrent = 0, UpdatedRunID = @RunID OUTPUT inserted.CustomerID INTO @Changed (CustomerID) FROM Lab.DimCustomer AS d JOIN #Valid AS v ON v.CustomerID = d.CustomerID WHERE d.IsCurrent = 1 AND (d.City IS DISTINCT FROM v.City OR d.Country IS DISTINCT FROM v.Country OR d.CustomerSegment IS DISTINCT FROM v.CustomerSegment); SET @Expired = @@ROWCOUNT; -- …and insert their new versions INSERT INTO Lab.DimCustomer (CustomerID, FirstName, LastName, Email, Phone, City, Country, CustomerSegment, ValidFrom, InsertedRunID) SELECT v.CustomerID, v.FirstName, v.LastName, v.Email, v.Phone, v.City, v.Country, v.CustomerSegment, @BatchDate, @RunID FROM #Valid AS v JOIN @Changed AS c ON c.CustomerID = v.CustomerID; -- 5. New customers INSERT INTO Lab.DimCustomer (CustomerID, FirstName, LastName, Email, Phone, City, Country, CustomerSegment, ValidFrom, InsertedRunID) OUTPUT inserted.CustomerID INTO @New (CustomerID) SELECT v.CustomerID, v.FirstName, v.LastName, v.Email, v.Phone, v.City, v.Country, v.CustomerSegment, @BatchDate, @RunID FROM #Valid AS v WHERE NOT EXISTS (SELECT 1 FROM Lab.DimCustomer AS d WHERE d.CustomerID = v.CustomerID); SET @Inserted = @@ROWCOUNT; -- 6. Flag possible duplicates among the new customers INSERT INTO Lab.CustomerMatchReview (RunID, NewCustomerID, ExistingCustomerID, MatchReason) SELECT @RunID, n.CustomerID, e.CustomerID, CASE WHEN n.Phone = e.Phone THEN N'Same phone' ELSE N'Same name and city' END FROM Lab.DimCustomer AS n JOIN @New AS nw ON nw.CustomerID = n.CustomerID JOIN Lab.DimCustomer AS e ON e.IsCurrent = 1 AND e.CustomerID NOT IN (SELECT CustomerID FROM @New) AND (e.Phone = n.Phone OR (e.FirstName = n.FirstName AND e.LastName = n.LastName AND e.City = n.City)) WHERE n.IsCurrent = 1; UPDATE Lab.EtlRunLog SET Status = 'Succeeded', FinishedAt = SYSDATETIME(), RowsRead = @Read, RowsRejected = @Rejected, RowsSuperseded = @Superseded, RowsInserted = @Inserted, RowsExpired = @Expired, RowsType1 = @Type1 WHERE RunID = @RunID; COMMIT TRANSACTION; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; UPDATE Lab.EtlRunLog SET Status = 'Failed', FinishedAt = SYSDATETIME(), ErrorMessage = ERROR_MESSAGE() WHERE RunID = @RunID; THROW; END CATCH; END; GO - Observe: Commands completed successfully.
- Why it matters: Each statement is set-based and guarded by “only if different”, which is what makes the procedure idempotent. Type 1 runs before Type 2 so the new version copies already-corrected values either way.
- Confirm:
SELECT OBJECT_ID(N'Lab.usp_LoadDimCustomer');is not NULL.
Step 2 — Run it for 2026-01-15
- Do: Load the batch and read the log.
- Run:
EXEC Lab.usp_LoadDimCustomer @BatchDate = '2026-01-15'; SELECT RunID, Status, RowsRead, RowsRejected, RowsSuperseded, RowsInserted, RowsExpired, RowsType1 FROM Lab.EtlRunLog ORDER BY RunID; SELECT FeedRowID, Reason FROM Lab.EtlReject ORDER BY RunID, FeedRowID; SELECT NewCustomerID, ExistingCustomerID, MatchReason FROM Lab.CustomerMatchReview; - Observe: run 1 Succeeded: read 11, rejected 2, superseded 1, inserted 3, expired 3, Type 1 rows 3. Rejects:
9 Missing CustomerID,10 Invalid segment. Review:44 → 5, Same phone. - Why it matters: 11 = 8 accepted + 2 rejected + 1 superseded; the 8 accepted rows produced 3 new customers, 3 Type 2 changes (customers 4, 7, 18), Type 1 changes for customers 2 and 18 (3 rows — customer 18 had two versions) and one unchanged customer (9).
- Confirm:
SELECT COUNT(*), SUM(CAST(IsCurrent AS int)) FROM Lab.DimCustomer;→ 48, 43.
Step 3 — Inspect the history
- Do: Look at the customers who changed.
- Run:
SELECT CustomerID, CustomerKey, Email, City, CustomerSegment, ValidFrom, ValidTo, IsCurrent FROM Lab.DimCustomer WHERE CustomerID IN (2, 4, 7, 18) ORDER BY CustomerID, ValidFrom; - Observe: customer 2: 1 row, email
ben.carter@example.org. Customer 4: Leeds (to 2026-01-15) → York (current). Customer 7: Consumer (to 2026-01-15) → VIP. Customer 18: three versions — Milan Consumer (to 2025-04-01), Milan VIP (2025-04-01 to 2026-01-15), Rome VIP (current) — all with the new.comemail. - Why it matters: Type 1 corrected the email across history; Type 2 kept the city history. Customer 7’s 09:00 change to Business never appears — it was superseded within the batch.
- Confirm: for every customer, each version’s
ValidToequals the next version’sValidFrom.
Step 4 — Prove idempotency
- Do: Run the same batch again.
- Run:
EXEC Lab.usp_LoadDimCustomer @BatchDate = '2026-01-15'; SELECT TOP (1) RunID, Status, RowsInserted, RowsExpired, RowsType1, RowsRejected FROM Lab.EtlRunLog ORDER BY RunID DESC; SELECT COUNT(*) AS DimRows FROM Lab.DimCustomer; - Observe: run 2 Succeeded with 0 inserted, 0 expired, 0 Type 1 rows (still 2 rejected — they’re logged per run);
DimRowsstill 48. - Why it matters: Schedulers retry; operators re-run. A load that isn’t idempotent turns every retry into a data problem.
- Confirm: no new review rows for run 2.
Step 5 — A failure is logged and changes nothing
- Do: Run with an impossible batch date.
- Run:
BEGIN TRY EXEC Lab.usp_LoadDimCustomer @BatchDate = '2025-01-01'; END TRY BEGIN CATCH SELECT ERROR_NUMBER() AS ErrorNumber, ERROR_MESSAGE() AS Msg; END CATCH; SELECT TOP (1) RunID, Status, ErrorMessage FROM Lab.EtlRunLog ORDER BY RunID DESC; SELECT COUNT(*) AS DimRows FROM Lab.DimCustomer; - Observe:
50701Batch date 2025-01-01 is earlier than an existing version.; run 3 Failed with that message;DimRows48. - Why it matters: Loading an older batch after a newer one would create overlapping versions. The guard refuses it, and the log row survives because it was written outside the transaction.
- Confirm: three runs in the log: Succeeded, Succeeded, Failed.
F. Final project — Customer sales mart
Build on the lab’s dimension. Work in the Lab schema; use the names given (the validation script relies on them). Allow 3–4 hours.
Part 1 — Fact table and load (30 points)
Create Lab.FactSales:
| Column | Type | Notes |
|---|---|---|
OrderLineID |
int |
Primary key |
OrderID |
int |
|
OrderDate |
date |
|
CustomerKey |
int |
The dimension version valid on OrderDate; −1 if none |
CustomerID |
int |
Business key |
ProductID |
int |
|
Quantity |
int |
|
NetAmount |
decimal(19,4) |
Quantity * UnitPrice * (1 - DiscountPct) |
Write Lab.usp_LoadFactSales @FromDate date, @ToDate date (half-open range) that loads non-cancelled order lines for the range, using a point-in-time lookup. It must be idempotent (re-running a range replaces it, never duplicates it), atomic, follow the Day 20 error pattern, and log a row in Lab.EtlRunLog (ProcessName = 'usp_LoadFactSales', RowsInserted = lines loaded). Load 2025 ('2025-01-01', '2026-01-01'), twice.
Success criteria: 360 lines, 198 orders, SUM(NetAmount) 63741.9445, no row with key −1 — after both runs.
Part 2 — Reporting view (20 points)
Create Lab.vSalesBySegment2025 returning one row per segment with NetSalesAsAtOrder (segment of the version linked to each fact) and NetSalesCurrentSegment (customer’s current segment), both decimal(12,2).
Success criteria:
| CustomerSegment | NetSalesAsAtOrder | NetSalesCurrentSegment |
|---|---|---|
| Business | 15068.81 | 17265.20 |
| Consumer | 31114.24 | 27974.96 |
| VIP | 17558.89 | 18501.79 |
Both columns total 63741.94. In two or three sentences, explain the differences (which customers move, and why).
Part 3 — Data-quality checks (15 points)
Write one query (or a procedure) returning a row per check with CheckName and Failures: (a) customers without exactly one current version; (b) gaps or overlaps between consecutive versions; (c) facts whose key doesn’t match the business key and date; (d) facts with key −1; (e) the last successful dimension run did not read every feed row (RowsRead ≠ rows in Lab.CustomerFeed).
Success criteria: 5 rows, all Failures = 0.
Part 4 — Monitoring (15 points)
(a) Write a blocking report (blocked session, blocker, wait type, wait time, both sessions’ SQL, the blocker’s open transaction count) and demonstrate it with two sessions, one of which runs Lab.usp_LoadFactSales while the other holds a lock on Lab.FactSales. (b) Write a query returning the five cached statements with the highest total logical reads whose text mentions FactSales or DimCustomer, with execution count and average elapsed time in milliseconds.
Success criteria: (a) the load appears as blocked with a lock wait (LCK_M_…) while the other session’s transaction is open, and completes after it ends; (b) up to 5 rows, including the fact load’s INSERT.
Part 5 — Design note (20 points)
Write about one page (in a comment block or a separate document) covering: the grain of the fact; which attributes are Type 1 and Type 2 and why; how idempotency and atomicity are achieved; what happens with late-arriving customers; indexes you added and why; two risks or limitations and what you’d do next.
Rubric
| Area | Points | Excellent (full marks) | Adequate (about half) | Insufficient (none) |
|---|---|---|---|---|
| Part 1 — Fact load | 30 | Correct counts and total; idempotent; atomic; Day 20 error pattern; run logged; sargable range filter | Correct data but either not idempotent or no error handling/logging | Wrong counts, or duplicates on re-run |
| Part 2 — Report | 20 | Both columns correct; clear explanation naming customers 7, 13 and 18 | One column correct, or explanation vague | Numbers wrong |
| Part 3 — Checks | 15 | All five checks, correct logic, all 0 | Three or four checks | Fewer than three |
| Part 4 — Monitoring | 15 | Both queries correct and demonstrated; statement text extracted with offsets | One query works | Neither |
| Part 5 — Design note | 20 | Accurate, specific, honest about limits | Generic or partly inaccurate | Missing |
| Total | 100 | Pass: 70. The validation script checks parts 1–2 and the lab automatically; parts 3–5 are assessed by reading |
Validation
Run scripts/lessons/day30_validate_final_project.sql (download it). It checks the dimension (after the lab), the run log, rejects, the duplicate review, idempotency (by re-running the dimension load inside a transaction it rolls back), and parts 1 and 2. All 18 checks should show PASS.
Stretch challenges (optional)
- Late-arriving customer. Insert an order (in a Lab copy of the order tables, not
Sales) for customer 45, who isn’t in the dimension yet. Change the fact load to create an inferred member — a minimal dimension row flaggedIsInferred = 1— instead of using −1, and make the dimension load complete the row (Type 1 overwrite, no new version) when the customer arrives. - Monitoring the
Perfworkload. If you still have the Week 4 data, run the Day 22 baseline queries and usesys.dm_exec_query_statsandsys.dm_os_wait_stats(difference between two snapshots) to describe the workload in five sentences.
G. Reference solution and explanations
This is one good solution; others can score full marks.
Show the part 1 solution
CREATE TABLE Lab.FactSales
(
OrderLineID int NOT NULL CONSTRAINT PK_FactSales PRIMARY KEY,
OrderID int NOT NULL,
OrderDate date NOT NULL,
CustomerKey int NOT NULL,
CustomerID int NOT NULL,
ProductID int NOT NULL,
Quantity int NOT NULL,
NetAmount decimal(19,4) NOT NULL
);
CREATE INDEX IX_FactSales_OrderDate ON Lab.FactSales (OrderDate) INCLUDE (CustomerKey, NetAmount);
CREATE INDEX IX_FactSales_CustomerKey ON Lab.FactSales (CustomerKey) INCLUDE (NetAmount);
GO
CREATE OR ALTER PROCEDURE Lab.usp_LoadFactSales
@FromDate date,
@ToDate date
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @RunID int, @Rows int;
INSERT INTO Lab.EtlRunLog (ProcessName, BatchDate) VALUES (N'usp_LoadFactSales', @FromDate);
SET @RunID = SCOPE_IDENTITY();
BEGIN TRY
IF @FromDate IS NULL OR @ToDate IS NULL OR @FromDate >= @ToDate
THROW 50710, N'Supply @FromDate < @ToDate (half-open range).', 1;
BEGIN TRANSACTION;
DELETE FROM Lab.FactSales
WHERE OrderDate >= @FromDate AND OrderDate < @ToDate;
INSERT INTO Lab.FactSales (OrderLineID, OrderID, OrderDate, CustomerKey, CustomerID, ProductID, Quantity, NetAmount)
SELECT l.OrderLineID, o.OrderID, o.OrderDate,
COALESCE(d.CustomerKey, -1),
o.CustomerID, l.ProductID, l.Quantity,
l.Quantity * l.UnitPrice * (1 - l.DiscountPct)
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
LEFT JOIN Lab.DimCustomer AS d
ON d.CustomerID = o.CustomerID
AND o.OrderDate >= d.ValidFrom AND o.OrderDate < d.ValidTo
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= @FromDate AND o.OrderDate < @ToDate;
SET @Rows = @@ROWCOUNT;
UPDATE Lab.EtlRunLog
SET Status = 'Succeeded', FinishedAt = SYSDATETIME(), RowsRead = @Rows, RowsInserted = @Rows
WHERE RunID = @RunID;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
UPDATE Lab.EtlRunLog
SET Status = 'Failed', FinishedAt = SYSDATETIME(), ErrorMessage = ERROR_MESSAGE()
WHERE RunID = @RunID;
THROW;
END CATCH;
END;
GO
EXEC Lab.usp_LoadFactSales '2025-01-01', '2026-01-01';
EXEC Lab.usp_LoadFactSales '2025-01-01', '2026-01-01';
SELECT COUNT(*) AS Lines, COUNT(DISTINCT OrderID) AS Orders, SUM(NetAmount) AS Net,
SUM(CASE WHEN CustomerKey = -1 THEN 1 ELSE 0 END) AS UnknownKeys
FROM Lab.FactSales;
Explanation. “Delete the range, insert the range” in one transaction is the simplest idempotent pattern for a fact that can be rebuilt from source; readers see either the old or the new range, never a mix. For very large facts, partition switching (Day 25) does the same with metadata operations. NetAmount keeps four decimal places so totals match the source exactly; round only for presentation.
Show the part 2 solution
CREATE OR ALTER VIEW Lab.vSalesBySegment2025
AS
WITH AsAt AS
(
SELECT d.CustomerSegment, SUM(f.NetAmount) AS Net
FROM Lab.FactSales AS f
JOIN Lab.DimCustomer AS d ON d.CustomerKey = f.CustomerKey
WHERE f.OrderDate >= '2025-01-01' AND f.OrderDate < '2026-01-01'
GROUP BY d.CustomerSegment
),
CurrentSeg AS
(
SELECT d.CustomerSegment, SUM(f.NetAmount) AS Net
FROM Lab.FactSales AS f
JOIN Lab.DimCustomer AS d ON d.CustomerID = f.CustomerID AND d.IsCurrent = 1
WHERE f.OrderDate >= '2025-01-01' AND f.OrderDate < '2026-01-01'
GROUP BY d.CustomerSegment
)
SELECT COALESCE(a.CustomerSegment, c.CustomerSegment) AS CustomerSegment,
CAST(COALESCE(a.Net, 0) AS decimal(12,2)) AS NetSalesAsAtOrder,
CAST(COALESCE(c.Net, 0) AS decimal(12,2)) AS NetSalesCurrentSegment
FROM AsAt AS a
FULL OUTER JOIN CurrentSeg AS c ON c.CustomerSegment = a.CustomerSegment;
GO
SELECT * FROM Lab.vSalesBySegment2025 ORDER BY CustomerSegment;
Explanation. Three customers’ 2025 sales are attributed differently: customer 13 was Consumer until July 2025 (2,196.38 of their 2025 sales), now Business; customer 18 was Consumer until April 2025 (783.90), now VIP; customer 7 was Consumer throughout 2025 (159.00) and became VIP in January 2026. “As at order” answers what kind of customer bought this?; “current segment” answers how much do today’s VIPs spend? — both legitimate, and the report must say which it shows. FULL OUTER JOIN keeps a segment that appears in only one view.
Show the part 3 solution
WITH LastDimRun AS
(
SELECT TOP (1) RowsRead, RunID
FROM Lab.EtlRunLog
WHERE ProcessName = N'usp_LoadDimCustomer' AND Status = 'Succeeded'
ORDER BY RunID DESC
)
SELECT N'Customers without exactly one current version' AS CheckName, COUNT(*) AS Failures
FROM (SELECT CustomerID FROM Lab.DimCustomer GROUP BY CustomerID
HAVING SUM(CASE WHEN IsCurrent = 1 THEN 1 ELSE 0 END) <> 1) AS x
UNION ALL
SELECT N'Gaps or overlaps between versions', COUNT(*)
FROM (SELECT ValidTo, LEAD(ValidFrom) OVER (PARTITION BY CustomerID ORDER BY ValidFrom) AS NextFrom
FROM Lab.DimCustomer) AS x
WHERE NextFrom IS NOT NULL AND NextFrom <> ValidTo
UNION ALL
SELECT N'Facts whose key does not match business key and date', COUNT(*)
FROM Lab.FactSales AS f
LEFT JOIN Lab.DimCustomer AS d
ON d.CustomerKey = f.CustomerKey AND d.CustomerID = f.CustomerID
AND f.OrderDate >= d.ValidFrom AND f.OrderDate < d.ValidTo
WHERE f.CustomerKey <> -1 AND d.CustomerKey IS NULL
UNION ALL
SELECT N'Facts with unknown customer (-1)', COUNT(*) FROM Lab.FactSales WHERE CustomerKey = -1
UNION ALL
SELECT N'Feed rows not read by the last dimension run',
ABS(r.RowsRead - (SELECT COUNT(*) FROM Lab.CustomerFeed))
FROM LastDimRun AS r;
Explanation. Checks are queries that should return zero; anything else is a defect to investigate. Running them after every load (and alerting on non-zero) turns silent data problems into visible ones. The last check is deliberately simple: the procedure derives the accepted count, so the independent test is that the run read every feed row (accepted, rejected and superseded rows are already reconciled inside the run log).
Show the part 4 solution
-- (a) Blocking report
SELECT r.session_id AS BlockedSession,
r.blocking_session_id AS BlockingSession,
r.wait_type, r.wait_time AS WaitMs, r.wait_resource,
SUBSTRING(bt.text, r.statement_start_offset / 2 + 1,
(CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(bt.text)
ELSE r.statement_end_offset END - r.statement_start_offset) / 2 + 1) AS BlockedStatement,
bs.open_transaction_count AS BlockerOpenTransactions,
lt.text AS BlockerLastBatch
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS bt
JOIN sys.dm_exec_sessions AS bs ON bs.session_id = r.blocking_session_id
LEFT JOIN sys.dm_exec_connections AS c ON c.session_id = r.blocking_session_id
OUTER APPLY sys.dm_exec_sql_text(c.most_recent_sql_handle) AS lt
WHERE r.blocking_session_id <> 0;
-- Demonstration
-- Window A: BEGIN TRANSACTION; UPDATE Lab.FactSales SET Quantity = Quantity WHERE OrderLineID = (SELECT MIN(OrderLineID) FROM Lab.FactSales);
-- Window B: EXEC Lab.usp_LoadFactSales '2025-01-01', '2026-01-01'; -- waits
-- Window C: run the blocking report; then in A: ROLLBACK TRANSACTION;
-- (b) Expensive cached statements touching the mart
SELECT TOP (5)
qs.execution_count,
qs.total_logical_reads,
CAST(qs.total_elapsed_time / 1000.0 / qs.execution_count AS decimal(12,1)) AS AvgElapsedMs,
SUBSTRING(st.text, qs.statement_start_offset / 2 + 1,
(CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1) AS StatementText
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE st.text LIKE N'%FactSales%' OR st.text LIKE N'%DimCustomer%'
ORDER BY qs.total_logical_reads DESC;
Explanation. most_recent_sql_handle from sys.dm_exec_connections shows what an idle blocker last ran — usually the clue to an uncommitted transaction. The DELETE in the load needs exclusive locks on the rows window A has locked, so it waits (LCK_M_X or similar, depending on which lock it requests first). total_elapsed_time is in microseconds. Query Store gives the same information with history; DMVs give the live picture.
Show the part 5 design-note points
- Grain: one row per order line of a non-cancelled order.
- Type 1: name, email, phone — corrections nobody analyses historically. Type 2: city, country, segment — reports need them as they were.
- Idempotency: change detection in the dimension (
IS DISTINCT FROM); delete-and-insert by date range in the fact. Atomicity: one transaction per load,XACT_ABORT ON,TRY…CATCH, run log outside the transaction. - Late-arriving customers: currently key −1 (and a failing check); better: inferred members (stretch 1).
- Indexes: filtered unique index for one current version;
(CustomerID, ValidFrom)for point-in-time lookups; fact indexes onOrderDate(load range, reports) andCustomerKey(joins). - Risks/next steps: same-day second change for a customer (the
CHECK (ValidFrom < ValidTo)would reject it — decide on “overwrite the day’s version”); deletes in the source aren’t handled; duplicate customers (44/5) need a merge process; scale — partition the fact by year and switch ranges in.
Show the stretch notes
- Add
IsInferred bit NOT NULL DEFAULT 0to the dimension. In the fact load, before inserting facts, insert a minimal current row (ValidFrom = '1900-01-01', placeholder name and segment,IsInferred = 1) for anyCustomerIDwithout a version. In the dimension load, treat an inferred current row as Type 1: overwrite all attributes and clearIsInferred, without creating a new version — so earlier facts keep their key and gain the real attributes. - Snapshot
sys.dm_os_wait_statsinto a temporary table, run the workload, snapshot again and subtract; ignore benign waits (e.g.SLEEP_TASK,LAZYWRITER_SLEEP,BROKER_…). Describe the top waits (for scans on a laptop typically I/O- or CPU-related) in plain language — and note that results depend on your machine.
H. Knowledge check
-
Understanding. Why do Type 2 dimensions use a surrogate key rather than the business key as the primary key?
-
Debugging. After a re-run of the customer load, three customers have two current rows. What two mechanisms in today’s design prevent this?
-
Query interpretation. With half-open intervals, a version is
ValidFrom = 2025-04-01,ValidTo = 2026-01-15. Which version applies to an order on 2026-01-15? -
Understanding. Why is the run-log row inserted before
BEGIN TRANSACTION? -
Debugging. A session shows
blocking_session_id = 72, but session 72 has no row insys.dm_exec_requests. What does that tell you, and how do you see what 72 did?
Show the answers
-
One customer has several versions, so the business key isn’t unique in the dimension; facts must reference a specific version. A surrogate key is unique per version, narrow, and independent of source-system key changes.
-
Change detection (only close a current version whose Type 2 attributes differ, so a second run finds nothing to close), and the filtered unique index
UX_DimCustomer_Current(one current row per customer), which would make a faulty load fail rather than corrupt the data. -
The next version (the one with
ValidFrom = 2026-01-15).ValidTois exclusive. -
So that a failed run is still recorded: if the log row were inside the transaction, the rollback would remove it along with everything else.
-
Session 72 isn’t running anything — it is idle with an open transaction holding locks (a common cause: a client that began a transaction and didn’t commit). Check
sys.dm_exec_sessions(open_transaction_count) andsys.dm_exec_connections.most_recent_sql_handlewithsys.dm_exec_sql_textto see its last statement.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Cannot insert duplicate key row … with unique index ‘UX_DimCustomer_Current’ | A new version was inserted before the old one was closed, or the close didn’t match | Close first (OUTPUT the IDs), then insert for exactly those IDs |
| The INSERT/UPDATE statement conflicted with the CHECK constraint “CK_DimCustomer_Interval” | Closing a version on the same date it started (two changes on one batch date, in separate runs) | Decide a rule (e.g. overwrite that day’s version); don’t create zero-length versions |
| Fact rows with key −1 | Order date before the customer’s first version, or customer missing | Check ValidFrom of the first version (here DateJoined); inferred members |
| Validation check 14 fails with −1/−1/−1 | The re-run raised an error | Run EXEC Lab.usp_LoadDimCustomer '2026-01-15'; yourself and read the error |
| Monitoring queries return VIEW SERVER STATE permission was denied | Missing permission | Grant VIEW SERVER PERFORMANCE STATE (2022+) or VIEW SERVER STATE |
| Counts don’t match the success criteria | Lab steps repeated with a different feed, or setup not re-run | Re-run the setup script, the lab, then the project |
Cleanup. Keep your project for your portfolio. To remove it, re-run the setup script (which drops and recreates the Day 30 objects) or:
DROP VIEW IF EXISTS Lab.vSalesBySegment2025;
DROP PROCEDURE IF EXISTS Lab.usp_LoadDimCustomer, Lab.usp_LoadFactSales;
DROP TABLE IF EXISTS Lab.FactSales, Lab.CustomerMatchReview, Lab.EtlReject, Lab.EtlRunLog,
Lab.CustomerFeed, Lab.DimCustomer;
To return the whole database to its starting point, run scripts/setup/04_reset_training_data.sql (destructive: it drops and recreates RetailLab, including every Lab and Perf object).
J. Completion checklist, recap and references
Completion checklist
- The lab load reported 11 / 2 / 1 / 3 / 3 / 3, and the re-run 0 / 0 / 0.
- Customer 18 has three versions; customer 7’s 09:00 change never appears.
- You watched a blocked session (
LCK_M_S) and found its idle blocker. - Final project: 360 fact lines, 63741.9445, segment report as specified.
- The validation script shows 18 of 18
PASS; your design note is written.
Recap
- A dependable load validates, de-duplicates, rejects with reasons, runs atomically, logs, reconciles — and is idempotent.
- Type 1 overwrites; Type 2 closes and inserts versions with half-open intervals and surrogate keys; one current row per business key, enforced by an index.
- Point-in-time joins attach the version valid on the fact’s date; reports must say whether they show “as it was” or “as it is”.
- DMVs show what’s running, who blocks whom, what we wait on and what costs most; Query Store keeps the history.
Course recap: where to go next
Over 30 days you have covered querying (Week 1), joins, sets, dates, strings and window-based analysis (Week 2), safe data modification and programmability (Week 3), and performance, advanced features and real-world patterns (Week 4). Next steps that build directly on this course:
- Re-do the Week 4 lessons on a copy of real data from your work (with permission), recording baselines and improvements.
- Practise reading execution plans for your own slowest reports.
- Explore areas this course only touched: Always Encrypted and row-level security, columnstore design, In-Memory OLTP, Service Broker, and Azure SQL / Fabric warehouse differences.
- Consider the Microsoft Learn learning paths and certifications for SQL Server and Azure data roles as a structured way to keep going.
References (Microsoft Learn)
You have reached the end of this track. Back to the track overview.