Level 5 — MasteryDatabases

Databases

90 minutes60 marksprintable — key stays hidden on paper

Time limit: 90 minutes Total marks: 60 Instructions: Answer all three questions. Show all reasoning: schema, SQL, proofs, and quantitative estimates. Assume PostgreSQL semantics unless stated otherwise.


Question 1 — Normalization, Functional Dependencies & Anomaly Proof (20 marks)

A logistics company stores shipment data in a single table Shipment:

ShipmentID TruckID DriverName DepotID DepotCity Weight RatePerKg Cost

The following functional dependencies (FDs) are known:

  • F1: ShipmentIDTruckID, DepotID, WeightF_1:\ \text{ShipmentID} \rightarrow \text{TruckID, DepotID, Weight}
  • F2: TruckIDDriverNameF_2:\ \text{TruckID} \rightarrow \text{DriverName}
  • F3: DepotIDDepotCityF_3:\ \text{DepotID} \rightarrow \text{DepotCity}
  • F4: DepotCityRatePerKgF_4:\ \text{DepotCity} \rightarrow \text{RatePerKg}
  • F5: Weight, RatePerKgCostF_5:\ \text{Weight, RatePerKg} \rightarrow \text{Cost}

(a) Compute the attribute closure {ShipmentID}+\{\text{ShipmentID}\}^+ under FF. Hence prove that ShipmentID is the (only) candidate key. (5)

(b) Identify every FD that violates 2NF and 3NF respectively, naming the exact anomaly (insert / update / delete) each causes. (4)

(c) Decompose the schema into BCNF. State each resulting relation with its primary and foreign keys, and prove your decomposition is lossless-join using the chase / common-attribute-key argument. (7)

(d) Is your BCNF decomposition dependency-preserving? If any FD is not preserved, identify it and state precisely why BCNF sometimes cannot preserve all dependencies. (4)


Question 2 — SQL Window Functions, Recursive CTE & Cost Estimation (22 marks)

Given tables:

Employee(empID PK, name, managerID FK->Employee.empID, deptID, salary)
Dept(deptID PK, deptName)

(a) Write a single query returning, for each department, the employee(s) with the second-highest salary (handle ties correctly so that if two people share the top salary the "second" is the next distinct salary). Use a window function. Justify your choice of DENSE_RANK vs RANK. (6)

(b) Write a recursive CTE that returns, for a given :rootID, every employee in that manager's subtree together with their depth (root = 0). Explain the anchor member, recursive member, and the termination condition. (6)

(c) For the query in (a), Employee has N=106N = 10^6 rows uniformly spread over D=500D = 500 departments. A B-tree composite index (deptID, salary DESC) exists. (i) Estimate the number of index leaf entries scanned to produce the result versus a full sort of the whole table, and state the asymptotic cost of each in Big-O. (4) (ii) The optimizer estimates cost as C=cioP+ccpuTC = c_{io}\cdot P + c_{cpu}\cdot T where PP = pages read, TT = tuples processed. Given cio=1.0c_{io}=1.0, ccpu=0.01c_{cpu}=0.01, a full scan reads P=12,500P=12{,}500 pages and processes T=106T=10^6 tuples, while the index-only plan reads P=4,000P=4{,}000 pages and processes T=106T=10^6 tuples. Compute both costs and state which plan the optimizer picks. (6)


Question 3 — Concurrency, Isolation & MVCC (18 marks)

Two transactions run concurrently on a PostgreSQL-style MVCC database. Table Account(id, balance) has row id=1, balance=100.

T1: BEGIN; UPDATE Account SET balance = balance - 50 WHERE id=1; ... COMMIT;
T2: BEGIN; SELECT balance FROM Account WHERE id=1; ... SELECT balance FROM Account WHERE id=1; COMMIT;

(a) Define the three read anomalies (dirty read, non-repeatable read, phantom read) and, for each of the four standard isolation levels, state in a table which anomalies are permitted. (6)

(b) Under READ COMMITTED, if T1's UPDATE and COMMIT occur between T2's two SELECTs, what values does T2 read? Which anomaly (if any) occurs? Explain using MVCC row versioning (xmin/xmax visibility). (5)

(c) Repeat (b) under REPEATABLE READ in PostgreSQL's snapshot implementation. Explain why no anomaly appears, and prove that this can create a write skew problem that even REPEATABLE READ does not prevent — give a concrete two-transaction example. (7)


Answer keyMark scheme & solutions

Question 1

(a) Closure of ShipmentID: (5) Start {ShipmentID}.

  • F1F_1 \Rightarrow add TruckID, DepotID, Weight → {ShipmentID, TruckID, DepotID, Weight} (1)
  • F2F_2 (TruckID→DriverName) → add DriverName (0.5)
  • F3F_3 (DepotID→DepotCity) → add DepotCity (0.5)
  • F4F_4 (DepotCity→RatePerKg) → add RatePerKg (0.5)
  • F5F_5 (Weight,RatePerKg→Cost) → add Cost (0.5)

{ShipmentID}+={\{\text{ShipmentID}\}^+ = \{all 8 attributes}\} → ShipmentID is a superkey. (1) No other attribute appears on the LHS of the "top" FD generating ShipmentID; ShipmentID never appears on any RHS, so it must be in every key ⇒ it is minimal and the unique candidate key. (1)

(b) With key = ShipmentID (all non-keys are non-prime): (4)

  • 2NF: since the key is a single attribute, there are no partial dependencies → no 2NF violation. (1) (Award for recognizing single-attribute key ⇒ automatically 2NF.)
  • 3NF violations are transitive dependencies:
  • F2F_2 TruckID→DriverName (2)
  • F3F_3 DepotID→DepotCity, F4F_4 DepotCity→RatePerKg, F5F_5 Weight,RatePerKg→Cost — none of these LHS are superkeys and RHS is non-prime → all violate BCNF/3NF. (1)

Anomalies (name any correct mapping): DriverName re-stored on every shipment of a truck ⇒ update anomaly; can't record a depot's city/rate with no shipment ⇒ insert anomaly; deleting last shipment loses truck's driver/depot city ⇒ delete anomaly.

(c) BCNF decomposition (each relation: LHS of an FD becomes a key): (7)

  • R1(TruckID,DriverName)R_1(\underline{\text{TruckID}}, \text{DriverName}) — from F2F_2 (1)
  • R2(DepotID,DepotCity)R_2(\underline{\text{DepotID}}, \text{DepotCity}) — from F3F_3 (1)
  • R3(DepotCity,RatePerKg)R_3(\underline{\text{DepotCity}}, \text{RatePerKg}) — from F4F_4 (1)
  • R4(Weight, RatePerKg,Cost)R_4(\underline{\text{Weight, RatePerKg}}, \text{Cost}) — from F5F_5 (1)
  • R5(ShipmentID,TruckID,DepotID,Weight)R_5(\underline{\text{ShipmentID}}, \text{TruckID}, \text{DepotID}, \text{Weight}) — residual with the key. (1) FKs: R5.TruckID→R1, R5.DepotID→R2, R2.DepotCity→R3, (R5.Weight, R3.RatePerKg via join)→R4.

Lossless proof (1): At each binary split R=RaRbR = R_a \bowtie R_b the common attribute is the key of one side (e.g. splitting off R1, common attr TruckID is key of R1 ⇒ RaRbRaR_a\cap R_b \to R_a holds). By the lossless-join test, if the shared attribute set is a superkey of one fragment the join is lossless; applying this at every step ⇒ overall decomposition is lossless. (1)

(d) Dependency preservation: (4) Each of F2,F3,F4,F5F_2,F_3,F_4,F_5 is fully contained inside one relation → preserved. F1F_1 (ShipmentID→TruckID,DepotID,Weight) lives entirely in R5 → preserved. (2) So this decomposition is dependency-preserving. However, note the general principle worth stating: BCNF is not guaranteed dependency-preserving (3NF is), because forcing every determinant to be a key can split an FD across relations whose join must be recomputed to check it. (2)


Question 2

(a) (6)

SELECT deptID, empID, name, salary
FROM (
  SELECT e.*,
         DENSE_RANK() OVER (PARTITION BY deptID ORDER BY salary DESC) AS rnk
  FROM Employee e
) t
WHERE rnk = 2;

Justification: DENSE_RANK assigns rank on distinct salary values, so if two employees tie for the top salary they both get rank 1 and the next distinct salary gets rank 2 — exactly "second-highest distinct salary." (3) RANK would skip to rank 3 after a two-way tie (leaving no rank 2), and ROW_NUMBER would arbitrarily pick one top earner as 1 and the other as 2 — both wrong for the requirement. (3)

(b) (6)

WITH RECURSIVE subtree AS (
    SELECT empID, name, managerID, 0 AS depth
    FROM Employee
    WHERE empID = :rootID              -- anchor member
  UNION ALL
    SELECT e.empID, e.name, e.managerID, s.depth + 1
    FROM Employee e
    JOIN subtree s ON e.managerID = s.empID   -- recursive member
)
SELECT * FROM subtree;
  • Anchor (2): selects the root at depth 0.
  • Recursive member (2): joins Employee to the working table on managerID = parent.empID, incrementing depth.
  • Termination (2): recursion stops when the recursive member returns no new rows (a leaf has no children satisfying the join), i.e. the UNION ALL fixpoint is reached. Assumes an acyclic hierarchy.

(c)(i) (4) The result touches only the top-2 salary rows per department. With the ordered composite index, the engine can walk each department's leaf range and stop after the second distinct salary: roughly O(DlogN+k)O(D \cdot \log N + k) where kk = rows returned (a few per dept), i.e. 500log2(106)50020=104\approx 500\log_2(10^6) \approx 500\cdot 20 = 10^4 leaf touches vs a full sort of all 10610^6 rows at O(NlogN)10620=2×107O(N\log N)\approx 10^6\cdot20 = 2\times10^7 comparisons. (Index plan asymptotically far cheaper.) (4)

(c)(ii) (6) C=cioP+ccpuTC = c_{io}P + c_{cpu}T.

  • Full scan: Cfull=1.012500+0.01106=12500+10000=22500C_{full} = 1.0\cdot 12500 + 0.01\cdot 10^6 = 12500 + 10000 = 22500. (2)
  • Index-only: Cidx=1.04000+0.01106=4000+10000=14000C_{idx} = 1.0\cdot 4000 + 0.01\cdot 10^6 = 4000 + 10000 = 14000. (2) Since 14000<2250014000 < 22500, the optimizer chooses the index-only plan. (2)

Question 3

(a) (6) Definitions (3, 1 each):

  • Dirty read: reading a row modified by another uncommitted transaction.
  • Non-repeatable read: re-reading the same row yields a different value because another committed txn updated it.
  • Phantom read: re-running the same range query returns a different set of rows because another committed txn inserted/deleted matching rows.

Permitted-anomalies table (3):

Isolation Level Dirty Non-repeatable Phantom
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ ✔ (✘ in PG)
SERIALIZABLE

(b) (5) Under READ COMMITTED each statement takes a fresh snapshot at statement start.

  • First SELECT (before T1 commits): sees old row version (xmax from T1 not yet committed / not visible) → 100. (1.5)
  • T1 commits (its new version balance=50 becomes visible). (1)
  • Second SELECT: new snapshot sees the committed version → 50. (1.5) The two reads differ → non-repeatable read occurs (permitted at READ COMMITTED). (1)

(c) (7) Under REPEATABLE READ, PostgreSQL takes one snapshot at the first statement of the transaction and reuses it for the whole txn. (1)

  • Both SELECTs see the version whose commit was visible at snapshot time → 100 and 100 — consistent, no non-repeatable read; xmin/xmax visibility rules hide T1's later-committed version. (2)

Write skew example (4): SERIALIZABLE-only anomaly; RR does not prevent it. Constraint: "at least one doctor must remain on call." Two on-call doctors A, B.

T1: SELECT COUNT(*) WHERE on_call=true;  -- sees 2
    UPDATE Doctor SET on_call=false WHERE name='A';
T2: SELECT COUNT(*) WHERE on_call=true;  -- sees 2 (own snapshot)
    UPDATE Doctor SET on_call=false WHERE name='B';
Both COMMIT.

Each read a snapshot of 2, each updated a different row (no write-write conflict, so RR/snapshot isolation lets both commit), but the final state has 0 on call — the invariant is violated. Only SERIALIZABLE (SSI) detects the read/write dependency cycle and aborts one. (4)

[
  {"claim":"ShipmentID closure yields all 8 attributes and cost is derivable last","code":"attrs={'ShipmentID'}\nfds=[({'ShipmentID'},{'TruckID','DepotID','Weight'}),({'TruckID'},{'DriverName'}),({'DepotID'},{'DepotCity'}),({'DepotCity'},{'RatePerKg'}),({'Weight','RatePerKg'},{'Cost'})]\nchanged=True\nwhile changed:\n    changed=False\n    for l,r in fds:\n        if l<=attrs and not r<=attrs:\n            attrs|=r; changed=True\nresult = attrs=={'ShipmentID','TruckID','DepotID','Weight','DriverName','DepotCity','RatePerKg','Cost'}"},
  {"claim":"Full scan cost = 22500","code":"C=1.0*12500+0.01*10**6\nresult = C==22500"},
  {"claim":"Index-only cost = 14000 and is cheaper","code":"Cf=1.0*12500+0.01*10**6\nCi=1.0*4000+0.01*10**6\nresult = (Ci==14000) and (Ci<Cf)"},
  {"claim":"Index leaf touches ~1e4 << full sort comparisons ~2e7","code":"import math\nidx=500*math.log2(10**6)\nfull=10**6*math.log2(10**6)\nresult = idx<11000 and full>1.9*10**7 and idx<full"}
]