diff --git a/slides/images/01-cover.png b/slides/images/01-cover.png new file mode 100644 index 0000000..3d94b0b Binary files /dev/null and b/slides/images/01-cover.png differ diff --git a/slides/images/02-contract.png b/slides/images/02-contract.png new file mode 100644 index 0000000..703d26d Binary files /dev/null and b/slides/images/02-contract.png differ diff --git a/slides/images/03-scope.png b/slides/images/03-scope.png new file mode 100644 index 0000000..00e038b Binary files /dev/null and b/slides/images/03-scope.png differ diff --git a/slides/images/04-architecture.png b/slides/images/04-architecture.png new file mode 100644 index 0000000..840254f Binary files /dev/null and b/slides/images/04-architecture.png differ diff --git a/slides/images/05-model.png b/slides/images/05-model.png new file mode 100644 index 0000000..cb35810 Binary files /dev/null and b/slides/images/05-model.png differ diff --git a/slides/images/06-claim.png b/slides/images/06-claim.png new file mode 100644 index 0000000..721591a Binary files /dev/null and b/slides/images/06-claim.png differ diff --git a/slides/images/07-state.png b/slides/images/07-state.png new file mode 100644 index 0000000..9dda4b4 Binary files /dev/null and b/slides/images/07-state.png differ diff --git a/slides/images/08-ownership.png b/slides/images/08-ownership.png new file mode 100644 index 0000000..737066f Binary files /dev/null and b/slides/images/08-ownership.png differ diff --git a/slides/images/09-recovery.png b/slides/images/09-recovery.png new file mode 100644 index 0000000..ca07103 Binary files /dev/null and b/slides/images/09-recovery.png differ diff --git a/slides/images/10-retries.png b/slides/images/10-retries.png new file mode 100644 index 0000000..01ee183 Binary files /dev/null and b/slides/images/10-retries.png differ diff --git a/slides/images/11-ui.png b/slides/images/11-ui.png new file mode 100644 index 0000000..8a4a847 Binary files /dev/null and b/slides/images/11-ui.png differ diff --git a/slides/images/12-tests.png b/slides/images/12-tests.png new file mode 100644 index 0000000..89531f2 Binary files /dev/null and b/slides/images/12-tests.png differ diff --git a/slides/images/13-tradeoff.png b/slides/images/13-tradeoff.png new file mode 100644 index 0000000..498dc0d Binary files /dev/null and b/slides/images/13-tradeoff.png differ diff --git a/slides/images/14-takeaway.png b/slides/images/14-takeaway.png new file mode 100644 index 0000000..7a90358 Binary files /dev/null and b/slides/images/14-takeaway.png differ diff --git a/slides/index.html b/slides/index.html new file mode 100644 index 0000000..3a0d089 --- /dev/null +++ b/slides/index.html @@ -0,0 +1,19 @@ + + + + + + Minimal PostgreSQL Job Queue - Interview Presentation + + + + + +
+ + + + + diff --git a/slides/slides.js b/slides/slides.js new file mode 100644 index 0000000..f80a257 --- /dev/null +++ b/slides/slides.js @@ -0,0 +1,259 @@ +const slides = [ + { + eyebrow: '01 / Opening', + title: 'Minimal PostgreSQL Job Queue', + subtitle: 'A small queue that proves safe claim, deterministic state, and visible execution.', + image: 'images/01-cover.png', + notes: [ + 'Open by framing the assignment: build an internal job queue, not a full queue product.', + 'The main promise is correctness: multiple workers can run, but a single job receives a single execution owner.', + 'The implementation is intentionally small so every important behavior is explainable during the interview.' + ] + }, + { + eyebrow: '02 / Problem', + title: 'What the Assignment Really Tests', + subtitle: 'Create jobs, claim them safely, manage states, and trace what happened.', + image: 'images/02-contract.png', + notes: [ + 'The assignment asks for job creation, worker claim, job status management, and execution tracking.', + 'The central invariant is: one job must not be claimed and executed by two workers at the same time.', + 'It also asks that worker failure behavior and the main status transitions are clear and testable.' + ] + }, + { + eyebrow: '03 / Scope', + title: 'Small Queue, Not a Queue Platform', + subtitle: 'The design keeps the model minimal and moves complexity into deterministic rules.', + image: 'images/03-scope.png', + notes: [ + 'There is one internal priority queue, not user-defined queues.', + 'There is no queue table and no worker table. Workers are ephemeral process threads configured through settings and environment variables.', + 'Authentication and advanced operator permissions are deliberately omitted for interview simplicity, but documented as production follow-ups.' + ] + }, + { + eyebrow: '04 / Architecture', + title: '1 Database, 2 Django Roles, 1 UI', + subtitle: 'The API exposes queue operations; the worker process claims jobs from PostgreSQL.', + image: 'images/04-architecture.png', + notes: [ + 'React is only the demo and observability surface. It does not own queue state.', + 'Django API creates jobs, lists jobs and events, exposes stats, and supports manual retry of failed jobs.', + 'The worker is a separate Django process with N configured threads; each thread claims work from PostgreSQL.' + ] + }, + { + eyebrow: '05 / Model', + title: 'Two Tables Are Enough', + subtitle: 'The job row carries state; the event row explains the history.', + image: 'images/05-model.png', + notes: [ + 'jobs stores type, payload, status, priority, available time, attempts, max attempts, lock owner, lock deadline, result, and timestamps.', + 'job_events is append-only observability: created, claimed, progress, lease renewed, retry scheduled, timeout requeued, succeeded, failed, and manual retry.', + 'Database constraints validate row shape: queued jobs have no lock, running jobs must have a lock, and terminal jobs must have a finish timestamp.' + ] + }, + { + eyebrow: '06 / Safe Claim', + title: 'PostgreSQL Chooses the Winner', + subtitle: 'Atomic claim uses row locks and SKIP LOCKED to avoid double execution.', + image: 'images/06-claim.png', + notes: [ + 'The worker selects the next eligible queued job ordered by priority, available_at, created_at, and id.', + 'Inside one transaction, it uses SELECT ... FOR UPDATE SKIP LOCKED, then marks the row running, increments attempts, and sets the lease.', + 'When many threads race, one locks the row; the others skip it instead of waiting and accidentally claiming the same job.' + ] + }, + { + eyebrow: '07 / State', + title: 'Status Transitions Are Explicit', + subtitle: 'The queue allows only meaningful movement between queued, running, succeeded, and failed.', + image: 'images/07-state.png', + notes: [ + 'The normal path is queued -> running -> succeeded.', + 'Failure or timeout can move running back to queued when attempts remain.', + 'When attempts are exhausted, the job becomes failed. Manual retry can move failed back to queued and resets the attempt count.' + ] + }, + { + eyebrow: '08 / Ownership', + title: 'Current Owner + Attempt Is the Write Token', + subtitle: 'A stale worker cannot complete or fail a job after ownership changes.', + image: 'images/08-ownership.png', + notes: [ + 'Completion, failure, progress, and lease renewal all filter by job id, running status, locked_by, and attempt.', + 'That means a worker that crashed and later wakes up cannot complete attempt 1 after the job was requeued and claimed as attempt 2.', + 'This is the practical guardrail that makes lease recovery safe.' + ] + }, + { + eyebrow: '09 / Recovery', + title: 'Worker Failure Is a Timeout, Not a Mystery', + subtitle: 'Expired leases are cleaned up deterministically.', + image: 'images/09-recovery.png', + notes: [ + 'Workers periodically run cleanup for running jobs whose locked_until is in the past.', + 'If attempts remain, the job is requeued and a timeout event is recorded. If attempts are exhausted, it becomes failed.', + 'This queue provides at-least-once execution, not exactly-once execution. External side-effect handlers should be idempotent.' + ] + }, + { + eyebrow: '10 / Retry', + title: 'Retries Are Bounded and Scheduled', + subtitle: 'Failures return to the queue with deterministic exponential backoff.', + image: 'images/10-retries.png', + notes: [ + 'fail_job either schedules the next attempt or marks the job failed when max_attempts is reached.', + 'Backoff is deterministic and stored through available_at, so workers do not need hidden memory.', + 'Idempotency keys prevent duplicate producer submissions for the same logical job.' + ] + }, + { + eyebrow: '11 / UI', + title: 'Demo-Friendly Observability', + subtitle: 'The UI shows state, pressure, attempts, leases, and event history while workers run.', + image: 'images/11-ui.png', + notes: [ + 'Dashboard polls jobs, stats, events, and health every 2 seconds.', + 'Jobs page uses limit-offset pagination, status/type filters, and refreshes every 2 seconds.', + 'Job detail polls the job and its timeline every 1 second; global events use cursor pagination and fetch older pages as the terminal log scrolls.' + ] + }, + { + eyebrow: '12 / Tests', + title: 'Tests Target the Risky Parts', + subtitle: 'The most important tests are around ownership, concurrency, and recovery.', + image: 'images/12-tests.png', + notes: [ + 'There are service-level tests for successful claim, deterministic ordering, idempotent creation, retry scheduling, exhausted failure, timeout cleanup, stale worker rejection, and lease renewal ownership.', + 'The PostgreSQL concurrency test runs multiple claimers and asserts only one claimed event exists.', + 'API tests cover job pagination, filters, cursor-based global events, and older-page loading.' + ] + }, + { + eyebrow: '13 / Trade-off', + title: 'PostgreSQL Now, Broker Later', + subtitle: 'PostgreSQL is ideal for this interview; Redis or a broker is the scale path.', + image: 'images/13-tradeoff.png', + notes: [ + 'The assignment requires PostgreSQL, and PostgreSQL makes the correctness story compact and inspectable.', + 'For very high throughput, cross-service distribution, or advanced queue features, I would move to a Redis-backed or dedicated broker design.', + 'The important point is that I did not hide over-engineering in the interview implementation; I documented it as a next architecture.' + ] + }, + { + eyebrow: '14 / Close', + title: 'What This Proves', + subtitle: 'Minimal implementation with production-aware guarantees.', + image: 'images/14-takeaway.png', + notes: [ + 'The system is safe because PostgreSQL owns the claim decision.', + 'It is deterministic because state transitions and row shapes are explicit and validated.', + 'It is demoable because the UI and events make execution visible without adding unnecessary queue models.' + ] + } +]; + +const deck = document.querySelector('.deck'); +const notes = document.getElementById('notes'); +const progressFill = document.getElementById('progressFill'); + +let current = Math.max(0, Math.min(slides.length - 1, Number(location.hash.replace('#', '')) - 1 || 0)); +let notesOpen = false; + +function renderDeck() { + deck.innerHTML = slides.map((slide, index) => ` +
+
+ ${slide.eyebrow} +

${slide.title}

+

${slide.subtitle}

+
+
${slide.title} visual
+
+ `).join(''); + update(false); +} + +function update(shouldUpdateHash = true) { + deck.querySelectorAll('.slide').forEach((slide, index) => { + slide.classList.toggle('active', index === current); + slide.setAttribute('aria-hidden', index === current ? 'false' : 'true'); + }); + + progressFill.style.transform = `scaleX(${(current + 1) / slides.length})`; + + if (shouldUpdateHash) { + location.hash = String(current + 1); + } + + renderNotes(); +} + +function renderNotes() { + const slide = slides[current]; + notes.innerHTML = `

Speaker notes - ${String(current + 1).padStart(2, '0')}. ${slide.title}

`; + notes.classList.toggle('open', notesOpen); +} + +function next() { + if (current < slides.length - 1) { + current += 1; + update(); + } +} + +function prev() { + if (current > 0) { + current -= 1; + update(); + } +} + +function toggleNotes() { + notesOpen = !notesOpen; + renderNotes(); +} + +document.addEventListener('keydown', (event) => { + if (['ArrowRight', 'PageDown', ' '].includes(event.key)) { + event.preventDefault(); + next(); + } + + if (['ArrowLeft', 'PageUp'].includes(event.key)) { + event.preventDefault(); + prev(); + } + + if (event.key === 'Home') { + current = 0; + update(); + } + + if (event.key === 'End') { + current = slides.length - 1; + update(); + } + + if (event.key.toLowerCase() === 'n') { + toggleNotes(); + } + + if (event.key === 'Escape' && notesOpen) { + notesOpen = false; + renderNotes(); + } +}); + +window.addEventListener('hashchange', () => { + const nextIndex = Number(location.hash.replace('#', '')) - 1; + + if (Number.isFinite(nextIndex) && nextIndex >= 0 && nextIndex < slides.length && nextIndex !== current) { + current = nextIndex; + update(false); + } +}); + +renderDeck(); diff --git a/slides/styles.css b/slides/styles.css new file mode 100644 index 0000000..4a215e3 --- /dev/null +++ b/slides/styles.css @@ -0,0 +1,290 @@ +:root { + --bg: #f8fbff; + --ink: #0f172a; + --muted: #64748b; + --accent: #2563eb; + --accent-strong: #7c3aed; + --line: #e2e8f0; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: + linear-gradient(180deg, #ffffff 0%, var(--bg) 42%, #eef6ff 100%); + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; +} + +.top-progress { + position: fixed; + top: 0; + left: 0; + z-index: 30; + width: 100vw; + height: 5px; + overflow: hidden; + background: rgba(226, 232, 240, .82); +} + +.top-progress span { + display: block; + width: 100%; + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-strong)); + transform: scaleX(0); + transform-origin: left center; + transition: transform 420ms cubic-bezier(.22, 1, .36, 1); +} + +.deck { + position: relative; + width: 100vw; + height: 100dvh; +} + +.slide { + position: absolute; + inset: 0; + display: grid; + grid-template-rows: max-content minmax(0, 1fr); + gap: clamp(14px, 2vh, 24px); + height: 100dvh; + padding: clamp(34px, 5vh, 58px) clamp(20px, 4.8vw, 74px) clamp(20px, 4vh, 42px); + opacity: 0; + pointer-events: none; + transform: translateY(10px); + transition: + opacity 180ms ease, + transform 220ms ease; +} + +.slide.active { + z-index: 2; + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.slide-header { + width: 100%; + margin: 0; + text-align: center; +} + +.eyebrow { + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + margin-bottom: 16px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, .86); + color: var(--accent); + font-size: 13px; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; + box-shadow: 0 10px 26px rgba(15, 23, 42, .05); +} + +h1 { + margin: 0; + font-size: clamp(42px, 5vw, 72px); + font-weight: 900; + letter-spacing: -0.055em; + line-height: .96; +} + +.subtitle { + margin: 18px auto 0; + color: var(--muted); + font-size: clamp(18px, 2vw, 25px); + font-weight: 650; + line-height: 1.35; +} + +.visual { + display: flex; + justify-content: center; + width: 100%; + height: 100%; + min-height: 0; +} + +.visual img { + display: block; + width: auto; + height: 100%; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.notes { + position: fixed; + left: 50%; + bottom: 34px; + z-index: 20; + width: min(1060px, calc(100vw - 60px)); + max-height: 38vh; + overflow: auto; + padding: 22px 26px; + border: 1px solid rgba(255, 255, 255, .13); + border-radius: 24px; + background: rgba(15, 23, 42, .94); + box-shadow: 0 24px 60px rgba(2, 6, 23, .35); + color: #e2e8f0; + opacity: 0; + pointer-events: none; + transform: translateX(-50%) translateY(24px); + transition: opacity 180ms ease, transform 180ms ease; +} + +.notes.open { + opacity: 1; + pointer-events: auto; + transform: translateX(-50%) translateY(0); +} + +.notes h2 { + margin: 0 0 10px; + color: #fff; + font-size: 18px; + letter-spacing: .01em; +} + +.notes ul { + display: grid; + gap: 7px; + margin: 0; + padding-left: 22px; +} + +.notes li { + color: #cbd5e1; + font-size: 15px; + line-height: 1.5; +} + +.notes code { + padding: 1px 5px; + border: 1px solid rgba(147, 197, 253, .15); + border-radius: 6px; + background: rgba(37, 99, 235, .16); + color: #bfdbfe; +} + +@media (max-width: 800px) { + .slide { + padding: 34px 24px 24px; + } + + .visual img { + height: 100%; + max-width: 100%; + } +} + +@media (max-width: 1536px), (max-height: 850px) { + .eyebrow { + height: 28px; + margin-bottom: 12px; + font-size: 11px; + } + + h1 { + font-size: clamp(32px, 4.1vw, 56px); + } + + .subtitle { + margin-top: 12px; + font-size: clamp(15px, 1.55vw, 20px); + } +} + +@media (max-height: 720px) { + .slide { + gap: 12px; + padding-top: 28px; + padding-bottom: 18px; + } + + .eyebrow { + height: 26px; + margin-bottom: 10px; + font-size: 11px; + } + + h1 { + font-size: clamp(30px, 6vh, 48px); + } + + .subtitle { + margin-top: 10px; + font-size: clamp(14px, 2.4vh, 18px); + } +} + +@media (max-height: 560px) { + .slide { + padding-top: 18px; + padding-bottom: 14px; + } + + .eyebrow { + display: none; + } + + h1 { + font-size: clamp(28px, 7vh, 42px); + } + + .subtitle { + font-size: clamp(14px, 3vh, 17px); + } +} + +@media (prefers-reduced-motion: reduce) { + .top-progress span, + .slide, + .notes { + transition: none; + } +} + +@media print { + body { + overflow: visible; + background: #fff; + } + + .top-progress, + .notes { + display: none; + } + + .deck { + height: auto; + } + + .slide { + position: relative; + min-height: 100vh; + page-break-after: always; + opacity: 1; + pointer-events: auto; + transform: none; + } +}