Files
tabdeal-job-queue/slides/slides.js

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();