Calendar file
People
People without events count as free all day — add anyone to include them in the query.
People without events count as free all day — add anyone to include them in the query.
Pick an endpoint and follow your data down the road, station by station. Inside each station: plain-English explanations on the left, the actual Java source with real line numbers on the right — hover any explanation to light up its line. Every number in the notes (loop counts, gates fired, events clipped) is computed from the file you uploaded. The rail on the far right is the full call chain; it tracks your scroll.
Every rule the system applies to odd input — what the code does, where it does it, how the test suite proves it, and (when a file is loaded) what that rule actually did to your data.
Same journey as the Data journey tab, but at memory level: for
every stage, what the code does step by step — and on the dark panels, what is actually held on
the heap at that moment, computed from your real file: the raw bytes as a hexdump, every Event
object, each person's 720-bit mask down to the twelve long words inside the BitSet,
and the scan table the scheduler walks.
"Suppose we want to turn this take-home into a real service that people can call." Worked through interview-style, in order: pin the requirements, meet every component (and why it earns its place), make the three big technology calls, draw the picture, then follow each request start to finish.
Everything below is justified by these eight lines — if a component doesn't serve one of them, it doesn't get to exist.
Every box in the diagram, explained like you'd explain it to a new teammate: what it is, why it's there, and what it actually holds.
The bouncer with the guest list.
tenant_id, throttles noisy clients, routes /calendars/… to the right service.The intake clerk — takes your paperwork, gives you a receipt.
202 + uploadId immediately.The calculator — the only box doing actual math.
MeetingScheduler as a fleet: stateless replicas behind the gateway, autoscaled on traffic.The company phonebook. Yes, it's a service.
person table and hands out canonical personIds.Alise (typo) and she's silently "free all day". That's a wrong meeting time nobody notices. IDs make typos impossible instead of undetected.GET /people?q=ali autocomplete so the UI can only pick people who exist.The filing cabinet. If it's not here, it didn't happen.
calendar, person, event tables (DDL below).UNIQUE constraint. See "why not Mongo/Dynamo" below.The sticky note on the monitor — fast, tiny, disposable.
avail:{personId}:{date} → value = the same 720-bit BitSet this demo builds, ~90 bytes. 100,000 people ≈ 9 MB — the whole company fits in the smallest Redis tier. Lost? Rebuild any mask from Postgres in one query.The conveyor belt — every job gets done exactly once-ish.
{uploadId, s3Key, calendarId}. Workers consume, parse, write.The company newspaper — everyone reads their own copy.
calendar.updated events, the outbox relay starts publishing to Kafka instead of calling Redis directly. Bonus: replaying the topic rebuilds any corrupted cache.The shoebox of receipts.
The kitchen staff — nobody sees them, everything depends on them.
CsvEventParser, fed by the queue.personIds → one Postgres transaction (all rows or none — same fail-fast rule as the demo) → rebuild affected masks in Redis → emit "updated".The questions an interviewer actually asks: why this database, why this cache, why this pipe — and what would make us change the answer.
JOIN express that in one line; in Mongo you'd pick a
document nesting and regret it the first time a query cuts across it.BEGIN … COMMIT.
Doable in Mongo, awkward; in Dynamo, painful beyond 100 items.UNIQUE (calendar_id, name) and
CHECK (end_min >= start_min) move validation into the database — the same
"invalid data cannot exist" philosophy as the Event constructor.calendar_id — clean, because no
query ever crosses tenants.avail:{personId}:{2026-07-06} →
a 720-bit bitmap (Redis SETBIT/GETBIT speak bitmap natively).
That's it. No sessions, no objects, no cleverness.MGET N masks → OR → scan. The same
microsecond BitSet logic as this demo — Station 3 of the Data journey, verbatim.SETEX.
Redis dies → everything still works, just slower. Never the other way around: Redis is
allowed to lie briefly (seconds-stale mask), never to be the only copy.CsvEventParser vs
MeetingScheduler becomes Calendar Service vs Availability Service. The
architecture is the class diagram, blown up.Calendar — id, ownerId, day bounds (07:00–19:00 today; a field, not a constant).Person — id, calendarId, name. Identity lives here, not in free-text strings.Event — id, personId, subject, startMin, endMin. Minutes-from-day-start, exactly like the BitSet.POST /calendars ← CSV body, returns calendarId GET /calendars/{id}/people ← who exists (drives the UI) GET /calendars/{id}/availability ?people=p1,p2&durationMinutes=60 GET /calendars/{id}/availability/first?… GET /calendars/{id}/free-windows?people=…
-- one row per uploaded calendar CREATE TABLE calendar ( id uuid PRIMARY KEY, owner_id uuid NOT NULL, day_start smallint NOT NULL DEFAULT 420, -- 07:00 in minutes day_end smallint NOT NULL DEFAULT 1140, -- 19:00 created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE person ( id uuid PRIMARY KEY, calendar_id uuid NOT NULL REFERENCES calendar(id), name text NOT NULL, UNIQUE (calendar_id, name) ); CREATE TABLE event ( id uuid PRIMARY KEY, person_id uuid NOT NULL REFERENCES person(id), subject text NOT NULL, start_min smallint NOT NULL, -- minutes from day start end_min smallint NOT NULL, CHECK (end_min >= start_min) ); CREATE INDEX event_by_person ON event(person_id);
-- upload = one transaction (all-or-nothing, -- same fail-fast semantics as the parser) BEGIN; INSERT INTO calendar …; INSERT INTO person …; -- batch INSERT INTO event …; -- batch COMMIT; -- availability read (cache miss): one round trip -- fetches every event needed to build the masks SELECT p.name, e.start_min, e.end_min FROM person p LEFT JOIN event e ON e.person_id = p.id WHERE p.calendar_id = :calId AND p.name IN (:people); -- who exists? (drives the UI + typo detection) SELECT name FROM person WHERE calendar_id = :calId ORDER BY name;
generate_series SQL version would be slower and unindexable.LEFT JOIN keeps people-without-events visible — the "free all day" rule
becomes explicit instead of accidental.All the components from section 2, wired together. Solid lines are the synchronous request path; the dashed line is the async invalidation that follows every upload.
CsvEventParser vs MeetingScheduler.calendarId → query forever.
Today's stateless re-upload-per-query becomes GET with an id.Every API, walked start → finish. Green is where the caller gets their answer.
The write path. Rare, chunky, validation-heavy — so it's asynchronous.
{uploadId, s3Key}uploadId in ~50 ms — before any parsingGET /uploads/{id} flips to ready; webhook firesIf a row is bad: the job lands in the dead-letter queue with the
line-numbered parse error; the upload status becomes failed with that exact message.
Nothing partial is ever committed.
100× more frequent than uploads. Target: p95 < 100 ms — typically single-digit.
MGET one mask per person from Redis{ availableSlots: […] } — Postgres never touchedCache miss? One extra hop: SELECT that person's events from a
read replica → rebuild the 90-byte mask → SETEX into Redis → continue as above. First
query after an upload pays ~10 ms; every one after rides the cache.
MGET, same ORfirst short-circuits on the first hit; free-windows walks the gaps[{id, name}] — the UI can only submit real IDs, so typos die hereoutbox(calendar_id, 'updated') row. A relay reads the outbox and performs
invalidation (and webhook fan-out). Crash-safe: the message exists iff the data does.MGET, or maintain group masks updated incrementally on member change.person/event by
calendar_id hash; Redis keys already shard naturally. Nothing crosses tenants,
so sharding is embarrassingly clean.COPY per upload before reaching for distributed SQL.What it takes to make this production-grade. Click any box in the diagram for what that part is and why it's there; below it, each numbered upgrade explains one change in depth — click a title to expand it.
Pick an upgrade: on the left, what changes on the schedule; on the right, what changes in the data model — green lines are added, red lines go away.
people=Alise (typo) and the scheduler logs a warning and treats her as free all
day — a silently wrong meeting time. The edge cases below demonstrate it live.personIds. Uploads resolve names → IDs at ingestion (unknown name = hard error or
explicit "create person" flow); queries accept only IDs. The UI gets
GET /people?q= autocomplete, so a typo becomes impossible instead of undetected.Strictness becomes a policy flag per tenant: unknown_person = reject | warn | treat-as-free.
calendar(id, tenant_id, day_start,
day_end), person(id, calendar_id, name), event(id, person_id, subject,
start_min, end_min). Uploads are transactions; queries hit indexes
(event(person_id)). Raw files go to object storage for audit/replay.Partition by tenant when big; events are tiny rows, so a single primary + read replicas carries this design very far.
202 Accepted + uploadId. Workers parse, validate, resolve IDs, write the DB
transaction, rebuild the affected masks in Redis, then emit a calendar.updated
event. Callers poll GET /uploads/{id} or receive a webhook.Failed jobs land in a dead-letter queue with the line-numbered parse error attached — same fail-fast philosophy, now asynchronous and retryable.
SETBIT avail:{personId}:{date} in Redis. A query is N mask reads + OR + scan —
zero DB on the hot path. Availability nodes are stateless, so horizontal scale is a replica
count; Redis loss degrades to replica-DB rebuilds, not downtime.At extreme read volume, precompute per-team combined masks for the common "whole team free?" query.
tenant_id), every table keyed by tenant, row-level checks in services. API keys +
scopes for machine callers (availability:read, calendar:write). Rate
limits per key. Subjects are the sensitive field — encrypt at rest, omit from availability
responses entirely (they never mattered to the math)./v1), idempotency
keys on uploads, RFC 7807 application/problem+json errors (the line-numbered parse
error slots in perfectly), pagination on list endpoints, OpenAPI spec generated from the
controllers.nextClearBit jumps) so a 45-minute meeting can start at
08:30. The BitSet model survives all of it — it just gets one mask per (person, date).java.util.logging warnings nobody
reads; no way to know the service is slow or wrong before users do.Below: the asks that change the model, not just the plumbing — "what if the requirements themselves grew?"
day_start/day_end per calendar — the code change is passing a real
WorkingDay instead of new WorkingDay(). Whole day = 1440 bits =
180 bytes; the BitSet approach doesn't blink. Clipping, scanning and caching are already
written against workingDay.getLength(), not the number 720.The demo's own domain object made this a config change, not a redesign —
that's the payoff of not scattering 420 and 1140 through the code.
start_at timestamptz, end_at timestamptz, index on
(person_id, start_at)); ingestion slices them into per-date masks in the
calendar's timezone (a 23:00–02:00 event marks two dates — clipping logic reused). A week query
= 7 masks per person, OR per date, scan per date; answers merge. Recurrences stored as RRULE and
materialized into a rolling window (e.g. 90 days ahead) by a scheduled job — queries never
expand rules at read time.-- events for one person-week, cache-miss path SELECT start_at, end_at FROM event WHERE person_id = :p AND start_at < :week_end AND end_at > :week_start; -- Redis: avail:{personId}:{2026-07-06} → 1440-bit mask
Storage math: 1440 bits/day × 365 days × 100k people ≈ 6.5 GB of masks — cache only the hot window (±30 days), rebuild cold dates from the DB on demand. DST: store UTC, build masks in the calendar's IANA zone, accept that one day a year has 1380/1500 minutes.
BitSet.set() twice is a no-op. Fine for "is anyone busy?", useless for "Alice is
double-booked, warn her" or "the room fits 2 meetings".conflict table +
surfaced in the upload report and a GET /calendars/{id}/conflicts endpoint.
Capacity semantics: where "busy" isn't binary (rooms, on-call rotations with N
responders), the mask generalizes from 1 bit to a small counter per minute —
byte[720] instead of BitSet; "available" becomes
count[m] < capacity. Same shape, same OR→scan pipeline, one type swap behind
the EventCalendar interface.-- find overlaps for the upload report (sweep in SQL)
SELECT a.id, b.id FROM event a JOIN event b
ON a.person_id = b.person_id AND a.id < b.id
WHERE a.start_min < b.end_min AND b.start_min < a.end_min;
POST/PATCH/DELETE /calendars/{id}/events/{eventId}), CSV upload demotes to a bulk
import. Concurrency via optimistic locking — version column, PATCH
carries If-Match, conflict → 409 and the client rebases. Masks update
incrementally: a moved event touches at most two (person, date) masks — clear old range, set new
range, or just rebuild that person-day from the DB (720 bits, microseconds) which is simpler and
equally fast. Every mutation writes the outbox row → cache refresh + webhook delta
(event.moved), so connected UIs update live.-- move an event with optimistic concurrency UPDATE event SET start_min = :new_start, end_min = :new_end, version = version + 1 WHERE id = :eventId AND version = :expected RETURNING version; -- 0 rows → 409 Conflict
History for free: an event_audit table fed by the same outbox
relay gives undo, "who moved my meeting", and replay-based debugging.
Why each piece exists, what it decided, and what calls what. Read top to bottom — it follows a request through the system.
The CSV is parsed with Apache Commons CSV in RFC 4180 mode rather than
String.split(","), because the sample data itself contains quoted subjects with
commas ("Lunch, then a walk"). The parser also strips a UTF-8 BOM and skips an
optional header row, since files exported from spreadsheets commonly have both.
CalendarParseException carrying the line number and raw line, instead of silently
skipping it — a silently dropped event would produce wrong availability, which is worse
than an error.EventParser is an interface even
though there is one implementation. Parsing is the natural seam of the system: tests inject
events directly, and a JSON or ICS parser could drop in without touching scheduling code.All domain objects are immutable and validate in the constructor: an Event
can never exist with a blank person or end < start. That means every layer
downstream can trust its inputs and skip re-validation.
[start, end). An event ending at 09:00 does not block a meeting starting at 09:00.
This is the convention real calendars use and it makes adjacent events compose without
off-by-one gaps.WorkingDay, not as scattered constants. Events outside the day are clipped to it;
a 06:00–08:00 event blocks only 07:00–08:00.Instead of interval arithmetic (sort, merge, subtract), each person gets a
BitSet of 720 bits — one bit per minute of the working day. Loading an event just
sets its minutes to 1.
OR per person, no merge logic at all.clear() bits the other still owns.
That's why masks here are derived and immutable — built once from the full event list,
rebuilt rather than mutated. Conflicts-as-a-feature and event deletion are answered in the
Interview Q&A tab (Q3) and Upgrades tab (cards 12–13): rebuild the person-day mask on change,
or swap the bit for a per-minute counter when counts matter.A query ORs the selected people's masks into one combined busy mask, then scans candidate start times. The free-check is a single call:
busy.nextSetBit(start) == -1 || nextSetBit(start) >= start + duration
— i.e. "the next busy minute is outside my window".
All input validation happens here at the public API boundary: null/empty people, blank
names, zero/negative/sub-minute durations, durations longer than the day. Whitespace-padded
duplicate names ("Alice ") collapse to one person.
The HTTP layer was added on top of the original CLI without touching core code — the same
MeetingScheduler serves both. The controller only decodes the multipart request
and maps domain objects to small response records; the service wires
parser → calendar → scheduler per request.
{ error } body via ApiExceptionHandler, so the UI can show the exact
parse error ("Malformed calendar entry at line 3 …") instead of a generic 500.One static file, no framework, no build step — served by Spring Boot's static handler.
The day board is painted on a graph-paper background: 1 pixel = 1 minute, so an event's
top and height are just its start minute and length.
Three layers of tests, ~40 cases:
A working model of the production write path from the Architecture and Upgrades tabs — running right here in the browser. Add, move and delete events on the schedule and watch the whole machine react in order: API → queue → a worker thread picks the job → row lock → transaction → outbox → commit → mask rebuild in Redis → webhook. Try "two clients edit at once" to see a real optimistic-lock conflict: one commit wins, the other gets a 409, refetches and retries.
The questions this project should be able to answer out loud — asked the way an interviewer would ask them, answered the way I'd answer across the table. Click a question to reveal its answer — try answering first.
Honest answer: the core is right and I'd keep it — BitSet masks, fail-fast parsing, the layer seams. But a fresh read finds real things to poke at:
MeetingScheduler.java:36), so a 60-minute meeting can only start at 07:00, 08:00, … —
exactly matching the README's expected output, which is why I chose it. But a real product wants a
sliding scan: a 45-minute meeting should be able to start at 08:30. The fix is small — step by
5–15 minutes, or jump with busy.nextClearBit() — and the README example survives
as a special case.AvailabilityService news up new WorkingDay() per request
(line 42). Works, but the 07:00–19:00 day should be injected configuration — the
WorkingDay(start, end) constructor already exists and is tested; the service just
doesn't use it.alice ≠ Alice, and a typo
is silently "free all day" (a logged warning the caller never sees). Fine for the exercise, the
first thing to fix on the way to production (see Q6).Event could be a Java 17 record (same
guarantees, half the code); java.util.logging → SLF4J; scheduler invariants
("first slot == head of all slots" is already tested) would suit property-based testing.The slot-grid problem, drawn: one 30-minute meeting at the start of the day, and a 60-minute meeting to place.
split(","). The sample data itself contains
"Lunch, then a walk" — a quoted comma. split corrupts that row
silently; an RFC 4180 parser handles quotes, and the wrapper adds BOM-stripping and header
detection because that's what real spreadsheet exports look like. Rule: never hand-roll a parser
for a format that has a spec and a library.OR each. The free-check is two lines
(isFree, lines 87–90). The alternative — sort, merge, subtract intervals — is
where off-by-one bugs live. I traded a few bytes for an algorithm with no edge cases left.parsing /
domain / repository / scheduling / service /
controller — each layer has one reason to change. The proof it works: the web API
was added later without touching a single core class.EventParser is an interface with one implementation — normally a smell,
justified here because parsing is the system's natural seam: tests inject events directly, and a
JSON or ICS parser drops in without the scheduler knowing.Event with
end < start cannot exist. Every layer downstream trusts its inputs instead of
re-checking them.[start, end). An event ending 09:00 doesn't block a
meeting starting 09:00 — how real calendars behave, and it makes back-to-back events compose
without fake conflicts.The engine, drawn: the README's own query — Alice + Jack, 60 minutes — as bits.
Sharp question — this is exactly where the trade-off lives. Two separate problems hide in it: a bit can't count, and a bit can't be safely un-set.
set() is idempotent — setting a busy minute twice is a
no-op. That's why overlapping meetings cost nothing for availability: busy is busy,
whether Alice has one meeting at 09:00 or three.clear(120, 180) — you just zeroed
09:30–10:00, minutes the review still occupies. The scheduler now happily books over the review.
Silent wrong answer, the worst kind.Why this project doesn't have the bug: the mask is never treated as the data. Look at
EventCalendar — the constructor builds all masks from the full event list, and no
method mutates them afterwards. Events are the source of truth; the BitSet is a throw-away
index derived from them, rebuilt per request. There is no delete-a-bit code path to get wrong.
And in production, three tiers depending on what you actually need:
int[720] per person-day. Add = increment, delete = decrement, busy =
count > 0, double-booked = count > 1, "room fits 2" =
count < capacity. Same build-then-scan pipeline, one type swap hidden behind
EventCalendar. (Upgrades tab, card 12.)The delete trap, drawn: Alice's overlapping standup and review — and what each strategy leaves behind when the standup is deleted.
The brief said "feel free to go above and beyond". What was added, in order of usefulness:
findFirstAvailableSlot
(short-circuits, returns Optional — tested to always equal the head of the full
list) and getFreeWindows (no duration; walks the gaps with
nextClearBit/nextSetBit).400 { error } with the exact parse
message.Dockerfile and fly.toml — the demo runs as
a container, not just on a laptop.MeetingScheduler untouched — the "above and beyond" is proof the core abstraction was
right, not a second system bolted on.Each answer follows from one of the key assumptions (documented in SOLUTION.md, pinned by tests, demonstrable live on the Edge cases tab):
| Edge case | Ruling & the assumption behind it | Where |
|---|---|---|
| Quoted comma in subject | Parsed as one field — CSV has a spec (RFC 4180), follow it. | CsvEventParserTest |
| Header row / BOM / blank lines | Recognised and skipped — real exports have them; they're noise, not errors. | with-header.csv |
| Malformed row (columns, bad time, end<start) | Fail fast with line number + raw line — a dropped row means a wrong answer, and wrong beats loud never. | invalid-row.csv |
| Event outside 07:00–19:00 | Clipped to the day (06:00–08:00 blocks only 07:00–08:00); fully outside → ignored. The day bounds are law. | MeetingSchedulerTest |
| Overlapping events | Merge silently — busy minutes are a set, not a sum. (BitSet gives this for free.) | overlapping.csv |
| Back-to-back events (end == next start) | No conflict — half-open [start, end) ranges. | meetingEndingExactlyAt… |
| Zero-length event (start == end) | Legal input, blocks nothing. | zeroLengthEvent… |
| Person not in the file | Free all day + logged warning — "all persons available" is satisfiable by someone with no events. (The weakest ruling; Q1 and Q5 both flag it.) | unknownPersonIs… |
| Duplicate / padded names ("Alice ") | Trimmed and deduped to one person; case stays significant. | duplicateAndWhitespace… |
| Bad duration (0, negative, 90 s, > 12 h) | Rejected with IllegalArgumentException → HTTP 400 — garbage questions get errors, not guesses. | validateDuration tests |
| Fully booked day | Empty list / Optional.empty() — "no slots" is an answer, not an error. | fully-booked.csv |
| Meeting exactly the whole day | One slot at 07:00 — the scan condition is <=, so ending exactly at 19:00 counts. | durationExactlyEqual… |
| The README example itself | Asserted verbatim, unit + end-to-end — the spec is a pinned regression test. | readme.csv |
The one-paragraph answer: make the calendar a stored resource instead of a request
parameter. Upload once → get a calendarId → query it forever. Postgres holds the
truth, Redis holds the 720-bit masks this demo already computes, and uploads become asynchronous
jobs so a big file can never block a query.
The shape of it:
EventParser seam.