docs: add presentation slide deck
BIN
slides/images/01-cover.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
slides/images/02-contract.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/03-scope.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/04-architecture.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/05-model.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/06-claim.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/07-state.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
slides/images/08-ownership.png
Normal file
|
After Width: | Height: | Size: 1003 KiB |
BIN
slides/images/09-recovery.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
slides/images/10-retries.png
Normal file
|
After Width: | Height: | Size: 1010 KiB |
BIN
slides/images/11-ui.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
slides/images/12-tests.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
slides/images/13-tradeoff.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
slides/images/14-takeaway.png
Normal file
|
After Width: | Height: | Size: 1022 KiB |
19
slides/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Minimal PostgreSQL Job Queue - Interview Presentation</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-progress" aria-hidden="true">
|
||||
<span id="progressFill"></span>
|
||||
</div>
|
||||
|
||||
<main class="deck" aria-live="polite"></main>
|
||||
<aside class="notes" id="notes" aria-label="speaker notes"></aside>
|
||||
|
||||
<script src="slides.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
259
slides/slides.js
Normal file
@@ -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: [
|
||||
'<code>jobs</code> stores type, payload, status, priority, available time, attempts, max attempts, lock owner, lock deadline, result, and timestamps.',
|
||||
'<code>job_events</code> 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 <code>SELECT ... FOR UPDATE SKIP LOCKED</code>, 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: [
|
||||
'<code>fail_job</code> 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) => `
|
||||
<section class="slide" data-index="${index}" aria-hidden="${index === current ? 'false' : 'true'}">
|
||||
<header class="slide-header">
|
||||
<span class="eyebrow">${slide.eyebrow}</span>
|
||||
<h1>${slide.title}</h1>
|
||||
<p class="subtitle">${slide.subtitle}</p>
|
||||
</header>
|
||||
<div class="visual"><img src="${slide.image}" alt="${slide.title} visual" /></div>
|
||||
</section>
|
||||
`).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 = `<h2>Speaker notes - ${String(current + 1).padStart(2, '0')}. ${slide.title}</h2><ul>${slide.notes.map((note) => `<li>${note}</li>`).join('')}</ul>`;
|
||||
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();
|
||||
290
slides/styles.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||