260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
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();
|