1 Summary
This document proposes an architecture for the rewrite of Hostify's channel synchronisation: the system that keeps every client's calendars, prices and reservations identical across Airbnb, Booking.com, the Expedia group and more than twenty further channels. It was written in October 2026 from a design session with the owner, five research passes with cited sources (the appendix lists them), and an independent review in two rounds whose findings and our answers are chapter 12; the design changed in sixteen places after the first round and in four more after the second.
1.1 What the system will do
A reservation made on any channel closes the same nights on every other channel, with a target of one to five seconds from our receipt of the booking to the channel's acceptance when the critical lane is not saturated, and a measured budget for every step; Booking.com reservations reach us by polling, up to twenty seconds after they exist, and Airbnb accepts a calendar update at once and confirms it later. A price set by a property manager reaches every channel as fast as that channel accepts updates; a cancellation reopens the nights. The system refuses to let two parties hold one physical unit for one night under any circumstances: when two channels have sold the same night before it could close it, it records a conflict, closes the listing everywhere and raises an alarm for a person, rather than choosing a guest by itself.
1.2 How it is built, in one paragraph
Clients are grouped into cells: a cell is about a hundred small clients or one large one, with its own computing nodes and its own database, and no cell depends on another. Inside a cell the client's world is modelled as a tree of small, independent processes on the Erlang virtual machine, the runtime built for telephone exchanges and used today by messaging systems that publish figures of millions of concurrent users: one process per property that alone decides which nights are taken, one per listing and channel that alone sends to that channel, and one state machine per reservation that carries it from request to checkout. The database of the cell is Postgres, which itself refuses two occupants of one unit on one night, so a defect in the code cannot overbook. A small shared control plane knows which client lives where and divides each channel's rate limit so that one client's price burst cannot delay another client's closes. A separate analytics database receives a copy of every change after the fact, so that reports and cross-client questions never slow a reservation. Cloudflare stands at the edge, receives every webhook and writes it to a durable queue before answering the channel, with the cell's own endpoint as the second receiver when the queue is unavailable.
1.3 Why the Erlang virtual machine, in plain words
The engine proposed for the cells is the Erlang virtual machine (the BEAM), with the Elixir language on top of it. It was built by Ericsson in the 1980s for telephone exchanges, where the requirement was not speed but that the system keeps running through faults and is repaired while live. Four properties of it map directly onto what a channel manager needs.
It runs millions of tiny, independent processes, each with its own memory, so this design gives every client, every property, every listing-channel pair and every reservation a process of its own. A fault in one is contained in one: a reservation whose channel sent something unexpected crashes alone, is restarted by its supervisor from the database in milliseconds, and no other reservation, listing or client notices. Where other runtimes would need a careful design to isolate clients from each other, here the isolation is the natural unit of the machine.
Those processes do not share memory; they send each other messages and each handles one message at a time. That is exactly the ordering a calendar needs: two changes to the same listing on the same channel are handled one after the other, because they wait in one queue, while changes to different listings run in parallel. The order per key that chapter 3 names as an invariant is not code we write; it is how the machine works.
Recovery is the model, not a plan. Processes are arranged in a tree where a parent restarts a child that fails, with a policy set per level; the tree of this design is the client's own hierarchy of properties, listings and reservations, so "what restarts what" is readable by anyone who knows the business. The machine also lets a module be replaced while the system runs, so a fix to one channel's adapter can reach a cell without a pause in sending.
And it is proven at a scale far above Hostify's: WhatsApp ran hundreds of millions of users on it with a team of about ten engineers, and Discord reports twelve million concurrent users and twenty-six million events a second on it; both figures are the companies' own and are in the appendix. The honest limits are stated too: no channel manager that discloses its design uses it, the channels publish no Erlang client libraries, and the hot-update path is used by few teams in practice and is treated here as an emergency tool rather than the way to deploy. Chapter 5 gives the mechanics; chapter 11 weighs it against the managed alternative.
1.4 What this gives against the five priorities
| Priority | How the design meets it |
|---|---|
| Not complex | Five kinds of part on day one (nodes, one Postgres, the edge, a vault, two tools) and eight at scale, counted in chapter 4; the hierarchy of the business (client, property, listing, reservation) is the hierarchy of the code |
| Scales horizontally | Growth is more cells; the platform repeats itself rather than growing |
| Fails rather than overbooks | The database refuses an overlap per physical unit; the allocation of nights passes through one process per property; a conflict is a visible state with a person at the end of it; a channel's refusal to close is treated as an incident, not a retry |
| Prices move fast | A change wakes the sender at once; a burst of a hundred thousand changes collapses to one intent per listing, channel and kind, sent in as many calls as the channel's bulk size needs; availability and restrictions always go before prices |
| Headroom for a peak | Each cell runs at under a third of its capacity; sends are bounded by the number of listings and channels, not by the number of changes; the shared channel limits keep a share for closes that prices cannot consume |
Visibility comes from a ledger that records every event received, every send intended and every attempt made, so that any question from support is one query, and from the runtime's own view of every process in every client. Security comes from verified webhooks, credentials in a vault, least privilege per role, guest data tokenised at write, and the cells and the stores kept in the European Union; where the edge's queue holds its messages is an item to confirm with the platform before guest data flows through it.
1.5 What it does not do
It does not prevent two channels from selling the same night in the seconds before the close reaches them, nor during an outage of the hosting platform, when no close is sent at all; no channel manager can, because the channels confirm bookings without asking. It shortens the first window to seconds, measures it continuously, and after an outage reconciles every listing and reports the conflicts against the outage length. It does not run without an operations function: cells are nodes and databases that someone runs, in exchange for having no ceiling imposed by a platform and for cells that can move to any provider; the edge stays with Cloudflare by choice.
1.6 The alternatives considered
A fully managed design on Cloudflare (Workers, Durable Objects, one SQLite database per client) was the second choice and is described in full: nothing to operate and the finest isolation, against a ceiling on what each unit may do, no interactive transactions, and no portability. A conventional design of one database with a pool of workers was third. Kafka, RabbitMQ and Redis were weighed for the critical path and kept out of it; Redis serves as a cache, a rate limiter and the bus to the analytics store. The market research found that the channel managers which disclose their design run on queues under mainstream languages and none on Erlang; the choice here is argued from fit and from documented facts, not from precedent, and chapter 11 says so.
1.7 What is open
Eight questions to Hostify would turn estimates into measurements, listed at the end. The recommended next step is a spike: one cell built without channel adapters and measured under the largest client's load against pass figures fixed in advance, so that the engine decision rests on numbers.
1.8 The independent review
A reviewer with no part in the design read the draft and reported thirty-eight findings, three of them critical, then read the revised text and reported fifteen more, four of them high, introduced by the revision itself; chapter 12 lists every one with what changed in the document, the one finding we declined and the one we declined in part, and why.
2 The brief and how this document was made
2.1 What Hostify asked
Hostify's core is a distributed system that keeps its clients' properties in step with the calendars of the online travel agencies: Airbnb, Booking.com, the Expedia group and more than twenty further channels. A reservation made on one channel must close the nights on every other one; a price set by the property manager must reach every channel; a cancellation must reopen the nights. The system holds the clients' data in Postgres today and serves, on average, about one thousand operations a minute.
Hostify holds Booking.com's Premier Plus connectivity tier for 2026, which is measured on reliability, so the rewrite starts from a record it must keep.
Hostify intends to rewrite it from zero. The brief, in the owner's order of priority:
- Not complex.
- Scales horizontally with ease.
- Fails rather than makes an overlapping reservation.
- A price change reaches the channels as fast as possible.
- Always headroom for a peak.
Visibility and security throughout. The goal is the best architecture for these five, not the most convenient one.
2.2 What this document is
It is the architecture we recommend, the alternatives we weighed and why they lost, the facts each claim rests on, and an independent review of the whole by a reader who had no part in writing it. It is written for two readers at once: a business reader who needs to know what the system will do, what it will not do and what the risks are, and a technical reader who will judge whether the design holds. The summary is for the first; chapters 3 to 11 are for the second; the review, the risks and the asks are for both.
It is not a specification. It names the shape, the invariants, the components and the decisions; it does not name every table column or every API path. Those come from the spike it recommends as the next step.
2.3 How it was made
The design was worked out in one brainstorming session on 12 October 2026 between the owner (Rossen, who leads the Hostify rewrite on our side) and the lead (the engineer who wrote this document), recorded in the project's wiki. Five research passes were then run by independent agents, one per area (the channels' partner APIs; Erlang/OTP for critical systems; Postgres, the outbox pattern and cell-based architecture; Cloudflare and Redis as alternatives; the channel-manager market and its public failures). Each pass was told to cite a source and a date for every fact and to mark a vendor's own figure as the vendor's. The raw reports are kept verbatim; the facts this document uses are listed with their sources in the appendix.
The draft was then given to a reviewer agent that had not seen the session and was asked to find what is wrong, unsupported or missing; after the text was revised, the same reviewer read it again for what the revision had broken. Both rounds' findings and our answers are chapter 12, including the ones we did not accept.
2.4 What we do not know yet
We have not read Hostify's current system; this document uses the owner's account of it and what the market makes public. Eight facts would change numbers in this document and are listed as asks at the end: what the thousand operations a minute count and how many clients and listings stand behind it, whether the new system is the same partner on each channel as the old one, the channel list with the number of listings on each, where today's Postgres runs and what fails in the current system, the size and the burst shape of the largest client, whether the tier-one channels offer idempotent writes and ordered webhooks, whether a public availability search reads the calendars at volume, and what a pending request does to the nights today. None of them changes the shape; one of them, the partner question, changes the shape of the migration.
3 The invariants: what holds whatever the engine
Ten rules were fixed before any technology was chosen, and every alternative in chapter 11 was measured against them. They are the architecture; the rest of this document is the choice of parts that satisfy them.
3.1 One truth, guarded by the database, per physical unit
Everything that occupies a physical unit for a night is a row in one table in one database: a reservation from any channel or from direct sales, a manual block, an owner stay, a hold for a pending request. The database refuses an overlap by itself with an exclusion constraint over the tenant, the unit and the half-open range of nights, restricted to rows in an occupying state. The unit, not the listing, is the key: a villa sold whole and as three rooms is three units, and a booking of the villa occupies all three; a room type of ten identical rooms is ten units, and a booking takes one concrete unit inside the transaction. A row-per-night model with a unique key over unit and night gives the same guarantee in a store without range constraints.
The consequence is the owner's third priority in its strongest form: a bug in the application cannot make two guests hold one unit for one night, because the store is the last judge and it does not read the application's reasoning. The availability a channel sees is derived from this table in the same transaction and is never a second source.
3.2 An intent carries a key and a scope, never a value
When something changes, the system records that a key must be synchronised and for which dates, not what to send. The sender reads the current values at the moment it sends. A stale push, where an older snapshot overtakes a newer one and reopens a sold night, is not made rare by this rule; it is made impossible, because there is no snapshot to be stale.
The same rule collapses a burst. A pricing tool that touches a hundred thousand nights produces, at most, one pending intent per listing, channel and kind, whose scope is the merged set of changed date ranges; the second change to a pending key widens the scope and leaves the row pending. The sender then makes as many calls as the channel's bulk size needs for that scope, and no more.
3.3 Order is per key, never global
The key is the tenant, the listing, the channel and the kind of data (availability, restrictions, rates, content). Within a key the order of operations is strict; between keys there is none, and everything runs in parallel. A hot listing slows itself and the bulk lane of its channel; it cannot slow the critical lane or another channel.
3.4 Three lanes
Closing nights on the other channels after a booking, and the restrictions that narrow what a channel may sell (minimum stay, closed to arrival or departure), are the critical lane and outrank every other send. Prices are the bulk lane. Content (photos, descriptions) comes last and is deferred first under load; nothing is ever dropped, because intents coalesce. Where a channel requires a price before it will open a night, the adapter declares that order and the sender follows it. The first metric of the system is the age of the oldest critical intent; the second is the time from a price change to the channel's acknowledgement.
3.5 The ledger is the queue, the audit and the support tool
Four tables record everything that happens: the inbound event as the channel sent it, with its personal fields tokenised, keyed by the channel's own id so a redelivery is recognised; the intent with its state (pending, in flight, acknowledged, failed with a count, dead) and its scope; every attempt with the request, the response and the latency; and every manual intervention. The same tables drive the work, prove what was done and answer support's question "why does Booking show the 14th as taken?" with one query by listing.
3.6 A channel is a plug-in that declares what it can do
Every channel implements the same small contract: what it supports (push or pull, webhooks or polling, which kinds, bulk size, rate limit per endpoint group, idempotency, the order of kinds it requires, whether it serves or consumes a calendar feed, whether a request's hold is released on expiry or kept until the channel releases it), a push of one kind for one listing and scope, a pull of reservations and changes since a time, a read of one reservation by the channel's id, the refresh of a credential, the verification of a webhook, and the rendering of a calendar feed where the channel pulls one. Its limits, cadences, hosts, authentication type and tier are a row in a configuration table, not code. The core never knows what Airbnb is.
3.7 One channel never poisons the others
Each channel has a cap on sends in flight, a hard timeout on every call and a circuit breaker that opens when its errors climb and is visible in the ledger when it does. A channel that hangs holds its own cap and nothing more; the critical lane keeps a share that is never zero, within the cell and within the shared partner limit.
3.8 Nothing fails silently, and a refusal on the critical lane is an incident
A reservation that cannot be allocated goes to a conflicts table and the listing is marked unsafe, which closes every night of its units on every channel until a person clears it. A channel that refuses a close with a final answer triggers an immediate pull of its reservations for the listing, a conflict if one overlaps, and the same unsafe state; a channel that refuses an open or a price with a final answer leaves the night closed, which is safe, and is a dead intent with an alarm and nothing more. A webhook for a listing nobody recognises is retried against the control plane's database and, if still unknown, goes to an unrouted table. An intent that has exhausted its retries is dead, not deleted. A channel token that expired is an alarm. Each is a table with a count on a dashboard and a person at the end of it; none is a log line.
3.9 The tenant is the first column of every key
Every table, index and query starts with the tenant, enforced by a mechanical check in the build, from the first day and while there is still one database. It costs nothing then and keeps every door open later: a client can be moved, isolated or given its own cell without a change to the code.
3.10 Money is an integer
An amount is a count of minor units with its currency beside it; no floating point anywhere in the path from a price rule to a channel. A listing has one currency per channel, and the sync path converts nothing.
4 The shape: cells, a thin control plane and a derived store
4.1 The system in one picture
The system has four parts, and the parts do not share fate.
At the edge, Cloudflare receives every webhook from every channel: its firewall and rate limiting stand in front, and a small function verifies the channel's signature, looks the external listing id up in a cached directory to find the client, and writes the event to a durable queue. Only when the queue has confirmed the write does the channel receive its acknowledgement; if the queue is unavailable the edge hands the event synchronously to the cell's own authenticated endpoint and acknowledges after the cell's commit; if both fail the channel receives an error and retries by its own policy. The cell drains its queue. The edge holds no business logic and no database credentials; the queue is the one piece of state it owns, it is a dependency on the inbound path and is named as such in chapters 9, 11 and 13, and the cell's endpoint is the second receiver that bounds an outage of it.
The cells do the work. A cell is a bounded group of clients, about a hundred small ones or one large one, with its own compute and its own database. Everything about a client lives in one cell: its listings, calendars, reservations, channel accounts, the record of every send, and the API its property managers use to read and change all of it. Cells never call each other.
The control plane is small and shared: which client lives in which cell, which external listing id belongs to which client, the configuration of every channel, the client's migration state, and one global rate limiter per channel partner account, because Booking.com's limit is per partner and shared by all clients. Postgres holds its truth; Redis caches it and runs the limiter. Every cell keeps a copy, so the control plane can be unreachable for a while without stopping anything. The limiter is the one shared resource on the critical path, and it is the one place where cells meet; chapter 8 describes how it is divided so that one cell's burst cannot starve another cell's closes.
The analytics store is derived. Every change in every cell is appended to a change feed; a reader per client moves it through a bus into one shared Postgres for reports, support's cross-client questions and the long-term history. It is written after the fact, read by heavy queries, and rebuilt from the cells for the period the cells retain. A reservation never waits for it.
4.2 Why cells
The owner's words during the session were that the attraction is natural isolation: a problem touches one client. The cell generalises that to a bounded group and makes the group the unit of everything that matters operationally.
| Unit of | What it means in a cell architecture |
|---|---|
| Isolation | A failed node, a bad deploy, a poisoned input or a runaway query affects the clients of one cell |
| Scaling | More clients means more cells; the platform does not grow, it repeats |
| Deployment | A new version goes to one cell, then to a share of cells, then to all; a cell with a problem is rolled back alone |
| Migration | A client moves from the legacy system in three states, legacy, shadow and live, one client at a time |
| Testing | A synthetic client in a production cell takes the chaos tests; nothing real is at risk |
| Backup and restore | Per cell, small and fast; a restore never touches another cell's clients |
| Support | The client is the boundary of an investigation |
The size of the cell is a dial, not a decision. The session considered the extreme of one listing per cell and one client per cell before settling on a group: the consistency boundary cannot be below the client, because linked calendars (a villa sold whole and as three rooms), multi-unit room types, shared rate plans and one channel token per host account all need a single transaction across several listings. A cell of one client is the right size for a large client; a cell of a hundred is the right size for small ones; both are the same software with a different directory entry. The figure of a hundred is a working assumption until Hostify's client and listing counts are known (the first ask at the end of this document); the derivation is the largest client's load, measured in the spike, against a cell's database ceiling.
The AWS guidance on cell-based architecture, published in 2023, says the same in its own words: a cell is a complete workload with no shared state, the router is the thinnest possible layer, the control plane may fail while the data plane keeps working, cells are sized so that the largest single customer fits and is stress-tested, and a full mapping of keys to cells is the option that gives control over hot cells and migrations. This design follows it on every point.
4.3 What stays shared, and how it is guarded
Three things are shared by every cell, and the design names each so that it is protected rather than forgotten.
The platform. An outage of the hosting provider is every cell at once. Nothing is sent or received for the duration, so the channels keep selling what we last sent them: prices go stale, and two channels may sell the same nights for as long as the outage lasts, because no close reaches them. When the platform returns the critical lane drains before anything else and the reconciler runs once for every listing; the conflicts it finds are a function of the outage length and the booking rate, and they are reported as such. The system cannot create an overbooking during an outage, because it cannot create anything; it also cannot prevent the channels from creating one. Both halves are stated to Hostify as known properties.
The code. One version runs in every cell, so a defect is every cell's defect. Hence the rollout by cells, and a schema that tolerates version n and n minus one side by side so that a rollback is a configuration change.
The channel resources. One Booking.com partner account, one Expedia partner account, each with one rate limit for all of Hostify's clients. The global limiter in the control plane holds counters and nothing else, is measured, divides each limit into a reserved critical share and bulk shares per cell weighted by that cell's listings on the channel, and every cell has a static share to fall back on when the limiter is unreachable, so a lost counter costs a second over the limit and a retry, never a stop.
4.4 The parts, counted honestly
The first priority is "not complex", and the honest answer is a list, not an adjective.
| Part | Kind | Day one, one cell | At several cells |
|---|---|---|---|
| Erlang nodes running the cell | compute | two nodes | two or three per cell |
| Postgres with a synchronous replica | database | one, holding the cell and the control-plane schema | one per cell, plus one for the control plane |
| Cloudflare edge: firewall, Worker, Queue | edge | yes | yes |
| Redis: cache and limiter | cache | absent; the static shares are the limiter | one, replicated |
| Redis Streams: the bus to derived consumers | bus | absent; the analytics reader polls the change feed | one |
| Analytics Postgres | database | a schema in the same Postgres, or absent | one |
| Secrets vault | security | yes | yes |
| Tools: shadow diff, client move, support | software | shadow diff and support; client move at the second cell | all three |
| Migration poller for Booking.com's reservation queue (chapter 10) | software | for the whole migration, then retired | retired |
Five kinds on day one, eight at scale, plus the migration poller for its season. The alternatives in chapter 11 are counted with the same rows: a conventional design needs the same vault, tools, replica and queue and counts the same five; the managed design counts eight. The chapter 3 rule that the tenant leads every key is what lets the control-plane schema and the analytics schema share the first cell's Postgres and leave it later without a change to the code.
4.5 The engine inside a cell
A cell is two or three Erlang virtual machines running Elixir and one Postgres with a synchronous replica. The domain is modelled as a tree of lightweight processes: a supervisor per client, under it a process per property that is the only allocator of its units' nights, under that a process per listing and per listing-channel pair that owns the sending, and a state machine per reservation that owns its lifecycle. Chapter 5 describes the tree; chapter 6 describes the database it writes to; chapter 11 says why this engine was chosen over a managed serverless platform and over a conventional service with workers.
5 Inside a cell: the domain as a tree of processes
5.1 Why a process per thing
The Erlang virtual machine runs lightweight processes, each with its own heap and mailbox, scheduled pre-emptively by the virtual machine itself. A newly spawned process is 338 words, about 2.7 kilobytes on a 64-bit machine, according to the runtime's efficiency guide; the default ceiling is about one million processes per node and the configurable maximum is above a hundred million. Each process handles one message at a time, so a process per key is a serialiser for free: two changes to the same listing-channel pair are processed in order because they wait in one mailbox, and two changes to different pairs run in parallel because they are different processes. The third invariant, order per key and parallelism between keys, is not implemented in a cell; it is the shape of the runtime.
The second property is isolation of failure. Processes are arranged in a tree where a parent restarts a child that crashes, with a strategy chosen per level. A listing's sender crashes on an unexpected response from a channel; it is restarted alone, rehydrates its state from the database and continues; its siblings never notice. This is the recovery model of the system, tested on every crash, not a plan in a document.
The third is the scheduler, which is a convenience rather than the fairness mechanism. It counts work in reductions (roughly, function calls) and switches processes after a budget of four thousand, and a per-process heap limit can kill a process that grows past a bound without touching any other; a garbage collection of one very large heap and a long call into native code are the two documented cases it does not interrupt, so the adapters keep their work in ordinary code and their heaps small. The resources a client can actually exhaust in a cell are the database connection pool, the row locks on nights during a cascade and the channel limiter, and none of them is protected by a scheduler; each has a per-client cap, which is the fairness mechanism of the cell.
The runtime has carried systems of this shape at a scale far beyond Hostify's: the published figures from Discord and WhatsApp, which are the companies' own, are in the appendix. What the market research did not find is any channel manager that names Erlang or Elixir for its sync; the vendors that disclose anything run queues (Kinesis, Kafka, RabbitMQ, SQS, MSMQ) under Node, Kotlin, PHP, Go or C#. The choice here is argued from the fit of the model to the problem and from the facts in chapter 11, not from precedent in this market.
5.2 The tree
A client is a supervisor. Under it:
- One process per channel account (the Airbnb token of a host, the Booking.com hotel credentials). It refreshes the token before it expires and raises an alarm when it cannot. It is a sibling of the properties, not a child of one, because every property of the client shares it.
- One supervisor per property, holding three things: the property process, a dynamic supervisor of listings, and a dynamic supervisor of reservations.
- The property process is the only allocator of nights for the property's units and for every listing linked to them. Every request to take or release nights goes through its mailbox, so two bookings for the same nights are decided in order, and the decision is written to Postgres under the exclusion constraint before it is answered. It keeps the units' occupancy in memory as a cache of the occupancy rows, hydrated on start and reloaded on any constraint error, because the constraint, not the cache, is the judge.
- One process per listing, and under it one state machine per listing-channel pair that owns the sending to that channel: it holds the pending intent, the dirty flag, the retry state and the circuit breaker's view of its channel.
- One state machine per reservation that owns the reservation's lifecycle.
The tree is five processes deep from the client to a listing-channel sender, and it stops there. It follows failure dependencies, not the client's organisation chart: a floor or a wing is an attribute of a listing, not a process, until it has an invariant of its own.
5.3 Which node owns a client
A cell has two or three nodes and a client lives on one of them at a time. Ownership is a lease row in the cell's Postgres: the owning node, a token that changes with every new lease, and a heartbeat. A node takes a client by writing the lease, starts the client's tree, and renews the heartbeat; a sibling that sees a stale heartbeat takes the lease with a new token and starts the tree itself. Because a paused node may wake and believe it still owns the client, every statement that changes an intent, a reservation or an occupancy carries the token it was started with and applies only if the lease still holds that token; a stale owner's writes fail and its tree stops. No distributed registry is used for correctness, because the ones available document that two nodes may briefly run the same process until a partition heals; the database decides, and a chaos case that pauses a node for longer than the lease is part of the suite.
5.4 The reservation as a state machine
A reservation is not a row that gets updated; it is a state machine with named states and named transitions, and every transition is an event with a condition and a side effect. Received, allocated, confirmed, checked in, checked out, archived; altered as a loop through allocation with new nights; cancelled as a release of nights; conflict when the nights were already taken; rejected or expired when a request was never answered. For an instant-booking channel the channel has already confirmed when the event arrives, so "confirmed" records the channel's decision and the allocation is the system's only act; for a request to book, a hold row with an expiry occupies the nights until the host answers or the timer fires, so a competing confirmed booking in the meantime is a conflict and not a surprise; whether the expired hold releases the nights or keeps them until the channel releases them (Airbnb keeps the dates blocked after a request expires) is a capability the adapter declares and the hold row carries.
Three things follow. Timeouts are part of the object: an unanswered request to book expires on a timer inside the reservation's own state, computed in the property's time zone, not in a scheduled job that scans tables. Conflict is a state the application shows, with the same word in the code, the dashboard and the support tool, not an error in a log. And an event the state machine has no transition for is a crash caught by the supervisor and counted, never a silent column; a test that enumerates every state against every event keeps the table complete.
Every transition is written to a reservation events table before the state machine answers, so the history exists outside the process and the process is rebuilt from it after a crash or a deploy. A reservation's process stops a few days after checkout and its events remain; the next event about it, a late review or a refund notice, starts it again. Payments, guest messaging and reviews are not in this system's scope; the state machine records the events that reach it and nothing more.
The reservation never writes an occupancy. It asks the property to allocate or release, and the property answers. If this rule is broken once for speed, the invariant has two homes and the constraint stands alone.
5.5 A booking, step by step
- Airbnb sends a webhook. At the edge the signature is verified, the external listing id is resolved to a client and a cell, the event is written to the cell's queue, and Airbnb receives its acknowledgement after the write. (Booking.com does not push reservations: its documentation asks the partner to poll a queue every twenty seconds and to acknowledge each reservation within thirty minutes, after which it falls back to e-mailing the property. For Booking.com, step 1 is a pull intent fired by the scheduler at that cadence, and the acknowledgement is a second call. Expedia pushes with retries and a signed payload. All three may deliver the same reservation more than once, and none promises order.)
- The cell drains its queue and appends the raw event to the inbound events table, keyed by the channel and the channel's own reservation id.
- The event is routed by that id. A known id goes to the existing reservation's state machine as a modification, a cancellation or a duplicate; only an unknown id starts a new state machine in the received state. Because channels do not promise order, the state machine treats the event as a hint and, where the channel offers a read by reservation id (Booking.com and Expedia document one; Airbnb's own status page names a read of a reservation by its confirmation code), reads the reservation's current state from the channel and reconciles to that; where no read exists it orders events by the channel's modification time and keeps the latest. A cancellation for an id nobody knows is held and applied when its booking arrives.
- The reservation asks its property to allocate the nights.
- The property checks its in-memory occupancy, then runs one Postgres transaction: insert the occupancy rows under the exclusion constraint, write one availability intent per other channel of the listing into the critical lane, and append the change to the change feed. If the constraint refuses, the transaction rolls back and the reservation moves to conflict; the listing becomes unsafe, which closes its units' nights on every channel until a person clears it.
- The property answers; the reservation moves to allocated, then confirmed, and notifies the listing-channel state machines of the other channels, which pick up their intents.
- Each listing-channel state machine claims its intent with the conditional update of chapter 6, reads the listing's current availability for the intent's scope, asks the channel's limiter for a slot in the critical lane, sends as many calls as the channel's bulk size needs, records each attempt with request, response and latency, and finishes the intent with the second conditional update, which returns it to pending if a change landed meanwhile. A channel that accepts asynchronously (Airbnb's calendar endpoint answers at once and confirms or fails later by webhook) leaves the intent awaiting confirmation until that webhook arrives or a timeout turns the silence into a final refusal; the age metric counts the wait.
- If a channel answers a close with a final refusal (a reservation already exists on its side for those dates, the listing is deactivated there), the sender does not retry and does not merely record a dead intent: it pulls the channel's reservations for the listing at once, raises a conflict if one overlaps, marks the listing unsafe, and alarms with the channel's reason text. A final refusal of an open or of a price (a rate plan missing, a price required first) leaves the night closed, which is safe: the intent is dead, an alarm names the configuration gap, and nothing is taken off sale elsewhere. The declared order of kinds in the adapter's capabilities is what keeps the second case rare; the taxonomy of final refusals, by direction, is part of each channel's conformance recordings.
The window in which an overbooking can happen is between step 5 and the last acknowledgement in step 7 for the slowest channel. The age of the oldest critical intent measures that window continuously; chapter 9 gives it a budget.
5.6 Cascades
A change at the property level, closing a building for repairs or applying a rate plan to forty listings, is one Postgres transaction at the property level, then a notification down the tree. The children refresh their cache and send out; they do not write the truth. The cascade is never the transaction, because forty processes writing forty rows is forty chances for thirty-eight to succeed.
5.7 Changing the code without stopping
The virtual machine can load a new version of a module while the old one runs, and a state machine carries its state across the change through a defined callback. For a system where a restart means rehydrating every client's processes from the database, this is an operational asset: a fix to a channel adapter can reach a cell without a pause in sending. The honest limit, stated by the Elixir release tooling itself and by the practitioners the research found, is that full release upgrades, which carry every process's state across a new release, are rarely used because every state record must be versioned; the practical middle, and the one we plan for, is to hot-load modules whose state shape did not change and to roll restarts across the cell's nodes for the rest, with the lease protocol above moving each client to the sibling node for the duration.
6 The data: one Postgres per cell, and the ledger
6.1 Why Postgres is the truth
The session weighed four stores for the truth: Postgres, SQLite per client (Cloudflare D1 or files on the node), Redis, and the Erlang-native tables (ETS, DETS, Mnesia). Postgres won on the one property the owner put third and we put first: the store itself must be able to refuse an overlap, lose nothing on a failover, and let a support engineer ask it anything.
Postgres gives an exclusion constraint over a range of nights per unit, which no other candidate has natively; interactive transactions, so the allocation in chapter 5 is one unit of work and not a batch of hopeful statements; synchronous replication on the managed offerings we would consider, so a failover loses no acknowledged write; partitioning by time for the ledger tables; and SQL for the questions nobody predicted. The alternatives are weighed in chapter 11; the one point worth repeating here is that an in-memory store is a cache in this design wherever it appears, never the truth.
6.2 The occupancy table and its constraint
Everything that occupies a unit is a row in one table, occupancy, with a kind: reservation, block (maintenance, owner stay), hold (a pending request to book, with an expiry). The rules, in the order a reviewer would check them:
- The range is half-open, from the check-in night to the check-out night excluded, so back-to-back stays do not conflict.
- The constraint is over the tenant, the unit and the range, with the
btree_gistextension for the scalar columns, and is partial: it applies only to rows in an occupying state, so a cancelled or expired row does not block its nights for ever. - An alteration that overlaps its own old nights is a single update of the range in one statement, so the row never conflicts with itself.
- A reservation of a villa that is three units is three rows, one per unit, inserted in one transaction; a reservation of a room type is one row for a concrete unit chosen in the transaction: the allocator locks the candidate unit's row, tests its ranges, and takes the first unit that is free, so two allocators cannot pick the same one.
- The night table's availability, a flag for a single-unit listing and a count of free units for a room type, is derived from this table in the same transaction and is a read model for the senders and the application; it is never written on its own.
- The constraint is enforced the way a unique index is: an insert that overlaps a row written by a transaction still in progress waits for that transaction and is then rechecked, at the default isolation level, so no serialisable mode is needed and inserts for different units never wait on each other. The application sees one of two errors and handles both: an overlap, which becomes the conflict state, and a deadlock between two simultaneous overlapping inserts, which is retried; the property process of chapter 5 serialises the writers of one property and removes the second case in practice. Postgres 18 expresses the same guarantee as a primary key with a
WITHOUT OVERLAPSclause, which is the same mechanism under a standard name.
A channel booking that lands on a block is a conflict with the block as the other party, and a person decides. A hold that expires either releases its nights by its own timer or keeps them until the channel reports the release, as the adapter's capability says; Airbnb keeps a request's dates blocked after expiry, so an Airbnb hold is of the second kind and the two calendars stay in agreement.
6.3 Which Postgres
Which managed Postgres runs the cells is a choice for the spike, with one condition fixed here: the provider must document that an acknowledged commit survives the loss of the primary, and the failover time it publishes becomes the figure in chapter 9. The research found zero-loss failover documented for the AWS Multi-AZ and Aurora offerings (failover in 30 to 120 seconds by their own figures), Google's Cloud SQL and AlloyDB (about 60 seconds), and PlanetScale's Postgres; Neon's quorum commit implies it without printing it; Crunchy Bridge and Supabase do not state it in their documentation. Synchronous replication has a liveness cost that is accepted deliberately: if the replica is lost, commits stop until an operator degrades to asynchronous replication, with an alarm; for a system whose third priority is to fail rather than overbook, halting is the right default.
6.4 The tables
A cell's database holds, for its clients, with the tenant as the first column of every key:
| Table | What it holds | Notes |
|---|---|---|
| property, unit, listing, channel_mapping | the physical units, the listings sold on channels and their external ids | a property's time zone is a column |
| occupancy | one row per unit and occupying range, with kind and state | the exclusion constraint lives here |
| night | availability, price by occupancy, restrictions, minimum stay per listing and night | availability derived from occupancy |
| rate_plan, price_rule | base prices and the rules derived from them | one change here is one transaction across its listings |
| reservation | the reservation as the channel knows it, with the channel's own id | unique on tenant, channel and external id |
| reservation_events | every transition of every reservation, in order | the history the state machine is rebuilt from |
| conflicts | occupancies that could not be allocated | an alarm, never a deletion |
| inbound_events | every webhook and poll result as received, personal fields tokenised | unique on channel and the channel's event id where the channel gives one, else on the reservation id and the channel's modification time; a redelivery of an identical event raises a counter on its row, so Booking.com's repeated delivery of an unacknowledged reservation is one row while a modification is a new one; partitioned by month |
| sync_intents | one row per key with lane, state, scope, dirty flag and the claiming node's token | the outbox |
| sync_attempts | every send: request, response, latency, result | partitioned by month; personal fields tokenised at write |
| changes | sequence, table, key, operation, time | the feed the analytics store reads |
| lease | which node of the cell owns which client, with a token and a heartbeat | the ownership protocol of chapter 5 |
| audit_log | every manual intervention |
6.5 The outbox, in the same transaction
The outbox is how the system avoids writing to two places. When the property allocates nights it also writes the intents, and when a rate plan changes it writes the night rows and the intents, in the same transaction. There is no moment at which the state has changed and the work to propagate it does not exist, and no moment at which the work exists and the state does not. A bus between the two would reintroduce that gap; this is why no broker sits on the critical path.
The process that owns the key is woken by a message when the row is written. The message is a hint, not a contract: a sweeper runs every few seconds over pending intents nobody has picked up and wakes their owners again, so a lost message costs seconds, never a lost send.
6.6 The life of an intent
An intent is keyed by tenant, listing, channel and kind, so there is at most one row per key that is pending or in flight. It carries a scope, the merged set of date ranges that changed, and no values. A change finds the row pending and widens its scope; a change finds it in flight, widens the scope and sets the dirty flag. Acknowledged intents are kept for the ledger; failed ones carry a count and a next attempt time; dead ones are the alarm. For a channel that accepts a write asynchronously the row has one more state, awaiting confirmation, which the channel's later webhook turns into acknowledged or into a final refusal, and a timeout turns into a final refusal by itself.
Two statements make the row safe against two senders and against a change that lands mid-send. A sender claims with one conditional update that moves the row from pending to in flight and records its lease token, and sends only if that update changed a row. A sender finishes with one conditional update that, in the same statement, returns the row to pending if the dirty flag is set and otherwise marks it acknowledged, clears the flag, and applies only if the row is still in flight under the same token; the sender loops while the row comes back pending. There is no window between reading the flag and writing the state, and a node that lost its lease cannot finish an intent it no longer owns.
Because the row carries no values, two senders cannot disagree about what to send: both would read the current state. The order and the coalescing that other designs buy with partitions and consumer groups are here a unique index, a scope column and two conditional statements.
6.7 How far one Postgres goes
Job systems built on Postgres with the same row-locking pattern publish throughput in the tens of thousands of jobs a second on one primary: River reports about 46,000 on a laptop, Oban about 17,700 on a single queue and 32,000 overall, graphile-worker about 15,600 unbatched. Each is the vendor's own measurement on its own hardware and the vendors themselves warn against comparing them; they are quoted as an order of magnitude, not a promise. The documented hazards of the pattern are also known and designed for: dead rows and index bloat on the pending index, a long transaction anywhere in the database holding back the vacuum, and hot pages at the head of the queue; the mitigations are short claim transactions that never span an HTTP call, batch claiming, time partitioning with dropped partitions, and aggressive per-table vacuum settings. Hostify's average today is about seventeen operations a second, and the peak of sends after coalescing is bounded by the number of listing-channel pairs, not by the number of changes; inbound events, night rows and ledger rows do scale with changes, and a seven-night booking on a listing with five channels writes on the order of thirty rows plus the feed. A cell of a hundred small clients is a small database by any measure; a cell of one large client is sized for that client. When a cell's database approaches its ceiling the answer is a smaller cell, which is a directory change and a client move, not a new design.
6.8 Time
A night is identified by the property-local date of check-in; instants are stored in UTC; every timer the state machines arm (a request's expiry, the stop of a process after checkout, the daily reconciliation) is computed from the property's time zone, so a daylight-saving change moves the UTC instant and nothing else. Month partitions are in UTC. Calendar feeds arrive with and without zones and the iCal adapter normalises them to the property's zone before anything reads them.
6.9 Prices
The night holds a base price per occupancy; rules (a derived rate plan at a percentage of another, weekend and seasonal adjustments, extra-guest fees, length-of-stay discounts, channel markups) are rows evaluated in the transaction that writes the night. Each channel's adapter projects the night onto what the channel accepts: Booking.com and Expedia rate plans with occupancy pricing, Airbnb's single nightly price with length-of-stay discounts. A listing has one currency on each channel; the sync path converts nothing, and a listing sold in two currencies on two channels is two prices written by the rules, not one price converted.
6.10 Personal data and retention
Request and response bodies in the ledger carry guest names and contact details. At write time these fields are replaced by tokens whose map lives in one table per cell, so the ledger stays complete for debugging and the personal data has one home. Retention is a table of its own: inbound events and attempts for a fixed number of months in the cell, conflicts until resolved plus a fixed period, the token map for a reservation until its retention ends or an erasure request arrives, at which point deleting the map erases the person from every table and every backup copy that is restored afterwards. Under the GDPR Hostify processes guest data on behalf of its clients, and the retention table is the operational half of that agreement.
6.11 What the change feed is for
Every transaction appends a row to the changes table with a sequence number. A job per client reads from the last delivered sequence, sends the batch to the analytics path and records how far it got. This is the only way data leaves a cell, and it is one-directional: nothing in the analytics store is read by a cell. The retention of the changes table is set longer than the longest analytics outage the design tolerates, because it is the recovery path for the bus.
7 The channels: twenty plug-ins behind one contract
7.1 Where the difficulty moves
With one or two channels the hard part of a channel manager is the transport. With twenty the hard part is the boundary between the core and the channels, and the test of the design is that the twentieth channel costs what the fifth did. The core described in chapters 5 and 6 does not change with the number of channels; a layer around it is designed so that it does not have to.
7.2 The contract
Every channel is an adapter that implements the same functions and nothing else:
| Function | What it answers |
|---|---|
| capabilities | push or pull; webhooks or polling; which kinds it accepts; bulk size per call; rate limit per endpoint group; whether writes carry an idempotency key; whether a write is confirmed at once or later by webhook; the order in which kinds must be sent; whether a request's hold is released on expiry or kept until the channel releases it; whether it serves a calendar feed, consumes one, or both |
| push(kind, listing, scope) | acknowledged; retry after a given time; or a final refusal with a reason from the channel's taxonomy |
| pull(listing, since) | reservations and changes since a time, in our canonical form |
| read(reservation id) | the reservation's current state as the channel holds it |
| refresh(account) | a new credential, or the reason none could be obtained |
| verify(webhook) | whether the signature is valid |
| feed(listing) | the listing's calendar as an iCal document, for channels that pull one |
The core asks the adapter what it can do and acts on the answer. A channel without webhooks receives pull intents from the scheduler at its cadence. A channel without a rates API never receives a rates intent. An iCal channel implements pull of the channel's feed, feed for our own, and nothing else. Our night model (availability, price by occupancy, length-of-stay rules, restrictions) is the superset; each adapter projects it down to what its channel accepts, and that projection is where the defects of twenty integrations live, in one place per channel.
7.3 Configuration is a row
Rate limit per endpoint group, bulk size, polling cadence, authentication type, sandbox and production hosts, tier and certification status live in a channels table in the control plane. When Booking.com changes a limit, a row changes; nothing is deployed. The adapter is only the behaviour.
7.4 Tiers
Not all twenty channels are equal, and the tier is a column that decides what each gets.
Tier one is Airbnb, Booking.com and the Expedia group with Vrbo: full two-way sync, partner certification, the tightest limits and most of the listings. Tier two is channels with a standard REST or XML API: push of rates and availability, pull of reservations. Tier three is iCal: we pull the channel's feed and the channel pulls ours, availability only, at a cadence the channel sets, with no acknowledgement of anything.
The three tier-one channels differ in ways the adapter contract has to carry, and the research pinned each to its documentation:
| Airbnb | Booking.com | Expedia group | |
|---|---|---|---|
| Reservations reach us by | webhooks; the channel's own status page has recorded delayed, missed and duplicated deliveries | pull: a queue polled every twenty seconds, each reservation acknowledged within thirty minutes or the property is e-mailed | push with retries up to about two and a half hours, then e-mail; payload signed with a yearly rotating secret |
| Idempotency on writes | not public; documentation is behind a partner login | none documented; writes are absolute values; unacknowledged reservations are redelivered | none documented; a retried notification carries a retry flag |
| Rate limit | not public; the terms say limits are set at the channel's discretion | per partner account per minute, from the published table: "All endpoints 10000"; /xml/reservationssummary 700; OTA_HotelDescriptiveContentNotif, OTA_HotelInvNotif, OTA_HotelProductNotif and OTA_HotelSummaryNotif 75 each; the availability and rates endpoints (OTA_HotelAvailNotif, OTA_HotelRateAmountNotif) have no row of their own and so fall under the overall figure | no number; on a 429 the partner backs off exponentially; about 180 updates per product per day expected |
| Bulk shape | an asynchronous calendar endpoint; failures arrive later as webhooks, not as HTTP errors | one hotel and one month per rates or availability request | up to five thousand updates per message |
| Pending request | the host has 24 hours; the dates stay blocked after expiry | partner 24 hours, guest 24 hours | Vrbo: a decline after 24 hours counts as a cancellation with a fee |
| iCal, where used | imports every three hours | every two hours, and not available to a property that has a connectivity partner | Vrbo every thirty minutes |
Three consequences follow for the core. The inbound path is keyed by the channel's own reservation id on every channel, because all three redeliver. The limiter for Booking.com is one counter per endpoint group, not one counter, because its seventy-five-per-minute limits on inventory and content endpoints bind long before the global ten thousand; the research put Hostify's thousand operations a minute at a tenth of the global cap, so the shaping of calls, not raw throughput, is what binds first. The budget of chapter 9 rests on the reading that availability and rates calls count only against the overall figure; the table is quoted in the appendix so that reading can be checked, and if Booking.com ever moved those endpoints into a seventy-five-per-minute group the same burst would drain in more than thirteen minutes and the critical share would have to be most of that group. And the adapters' final-refusal taxonomies are data the senders act on (chapter 5, step 8), not strings in a log.
Booking.com's own troubleshooting page for overbookings lists the causes it sees: outages that stop availability updates, late requests to close availability, rates not mapped for updates, and its automatic re-release of cancelled room nights, which is on by default and puts a cancelled night back on sale without checking current availability. The last is a channel behaviour the reconciler must expect: a cancellation on our side is followed by a check that the channel's view matches ours.
7.5 Restrictions are safety, not bulk
Minimum stay, maximum stay, closed to arrival and closed to departure decide what a channel may sell, and a stale one lets a channel sell a stay the property does not allow. They travel in the critical lane with availability. Some channels refuse to open a night that has no price; the adapter declares the order of kinds its channel requires in its capabilities, and the sender follows it, so "availability before prices" is the default and not a rule that produces refusals.
7.6 Isolation between channels
A shared pool of senders and twenty channels is the most dangerous combination in the system: one channel hangs, every sender waits on it, and the prices of the other nineteen stop moving. Three guards, all in the cell:
- A cap on sends in flight per channel. A sender does not take an intent for a channel at its cap.
- A hard timeout on every call.
- A circuit breaker per channel. When its error rate climbs the channel's lane pauses, the other nineteen continue, an alarm fires, and after a cooling period one call tests the water. The pause is a visible state in the ledger, not a silent backoff.
The critical lane keeps its own cap per channel that is never zero.
7.7 Authentication is work, not configuration
Airbnb issues an OAuth token per host account; Booking.com gives partner-level credentials; others issue a key per property. Refreshing a token is an intent of its own kind in the same ledger, served by the adapter's refresh function, with the same retries and the same alarm when it fails. An expired token nobody noticed is, in the experience of every channel manager, the most common cause of "the calendar stopped syncing"; the one vendor post-mortem the research found (Resly, July 2026) was a token-refresh race that stopped Booking.com sync and let properties receive bookings they could not accommodate. Here it is a dead intent with a count on the dashboard.
7.8 Reconciliation, in two directions
Deltas drift, whatever the transport, and the two directions need opposite rules. Reservations are imported from the channel: once a day, spread across the day, a job per property pulls the channel's reservations for its listings and allocates through the property any it does not know, which produces a conflict if one overlaps, never a push over it. Availability, restrictions and rates are pushed from ours: the job pulls the channel's view of the listing, compares it with ours, writes intents for the differences and a row for the drift. The reconciler draws on the bulk lane of the shared limiter and is sized against it, because a full pull per property per channel per day at thousands of properties is real traffic. It is the insurance against the defects nobody has found yet, and the drift rows are the measurement of how often the deltas fail.
7.9 Proving twenty adapters
The cost of twenty channels is keeping them correct for years. The adapters are tested with data, not with code: one conformance suite runs every adapter through the same canonical cases (an empty calendar, a full one, the bulk boundary, a refusal of each kind in the channel's taxonomy, an overlapping reservation, a linked-listing booking, a rate limit response, a timeout, a bad signature, a duplicate webhook, an out-of-order modification) against recorded real responses of that channel, captured by a tool during certification and kept with the code. A new channel is a new adapter and a new set of recordings; the suite is the same. A defect found in production becomes a recorded case first and a fix second. Each tier-one channel's sandbox is an environment the suite can be pointed at.
7.10 Certification is the schedule
Booking.com, Expedia and Airbnb run partner programmes with test scenarios and a review before a connection goes live; none publishes a duration. Booking.com's programme states its minimum requirements as a reservation fallback rate under five percent a month and a rates-and-availability error rate under ten percent a month; Expedia rates partners on capability adoption and success rates without publishing thresholds, and its top partners publish their own figures above 99.5 percent; Airbnb rates partners on API success rate over a rolling window. Hostify holds Booking.com's Premier Plus tier for 2026, so the rewrite inherits a measured record it must at least keep. This is a calendar, not an architecture, but it decides the order in which channels come online and it is the reason the adapter contract must be settled early: the certification of channel one is the first conformance recording of all twenty.
8 The control plane, the edge, the application API and the analytics store
8.1 The control plane
Outside the cells sits a small, shared set of facts that every cell and the edge need:
| Fact | Used by | Why it cannot live in a cell |
|---|---|---|
| directory: client to cell | the edge, the analytics readers, the support tool | it decides which cell a request goes to |
| index: external listing id to client | the edge | a webhook names Airbnb's id, not ours |
| channels: capabilities, limits per endpoint group, hosts, tier | every cell | one copy, changed without a deploy |
| channel accounts: which credentials exist | cells, with the secrets in a vault | |
| global limiter per channel partner account | every sender | Booking.com's limit is one for all of Hostify |
| migration flag per client: legacy, shadow, live | the edge, the cells, the diff tool | |
| unrouted events | the edge, support | a webhook for a listing nobody knows |
Postgres holds the truth of all of it; on day one that is a schema in the first cell's Postgres, and it moves to its own instance with the second cell. Redis, when there is more than one cell, caches the directory, the index and the channel rows, and runs the limiter as a token bucket in a Lua script: one atomic call, well under a millisecond on a local network, from any cell. Redis is the right tool for exactly this and for nothing on the critical path of a reservation: a lost counter on a failover costs one second over a channel's limit, a 429 and a retry, which the sender handles anyway. The reasons Redis is not the truth anywhere are in chapter 11.
The limiter has lanes. Each partner limit is divided into a reserved critical share, which only availability and restriction intents may draw on and which is never consumed by prices, and bulk shares per cell weighted by that cell's listings on the channel. A price burst in one cell therefore cannot delay another cell's closes, and a cell with one large client is not confined to the share of a cell with a hundred small ones. Every cell keeps the directory, the index and the channel rows in memory and a static share of each lane. The test the control plane must pass is stated as a sentence: if it vanishes, no reservation is lost or doubled and no cell stops; prices may run at a reduced rate until it returns.
8.2 The edge
Cloudflare's firewall, bot protection and rate limiting stand in front of every webhook endpoint, and a small function at the edge does three things: verify the channel's signature against the channel's secret, resolve the external id to a client and a cell from the cached index, and write the event to a durable queue for that cell. The channel receives its acknowledgement after the queue confirms the write; a failed write is an error to the channel, which retries by its own policy. Tier-one channels retry a webhook that errors and, in Expedia's documented case, fall back to e-mail after about two and a half hours of failures, so the edge's job is to be durable and fast, in that order.
Five facts about the edge are stated so that they are decided rather than assumed. The queue is a dependency of the inbound path: the platform's own incident record shows its queues unavailable for two and a half hours in June 2025, which is the length of Expedia's retry window, so the edge has a second receiver: when the queue write fails, the Worker hands the event synchronously to the cell's own authenticated endpoint and acknowledges after the cell's commit, and only when both fail does the channel see an error. Cloudflare Workers run at the point of presence nearest the sender, so a webhook carrying guest data is handled outside the European Union unless the platform's regional option is used; the design uses it for the Worker (the platform's data-localisation option; its page was not among those the research read and is verified in the same step), and where the queue stores its messages is not stated in the documentation the research read, so a written confirmation from the platform is a precondition of the first live client; if it cannot be given, the cell's endpoint becomes the only receiver and the queue is dropped, which the failure table already covers as the queue being unavailable. The channels' webhook secrets live in the edge platform's secret store, which is a second home for secrets beside the vault and is listed as such. The hop from the edge's queue to the cell is the cell's consumer pulling batches from the queue over the internet, at a short poll interval, over an authenticated connection. An unknown listing id is retried against the control plane's Postgres before it is recorded as unrouted, because a listing connected a moment ago is routine, not an alarm.
8.3 The application API
The property managers' app, Hostify's web application and any direct booking engine read calendars and reservations and write prices, blocks and restrictions through an API that lives in the cell, behind the directory, and is authenticated by Hostify's existing users and sessions. A write goes through the same property process and the same transaction as a channel booking: a manual block is an occupancy row, a price change writes night rows and intents, and the application receives the same conflict a channel would. Reads are served from the cell's Postgres; a read replica is added when the application's read volume competes with the senders, which the first and sixth asks at the end of this document size. The API is a component of the cell, not of the edge; the application resolves its client's cell from the directory on each request through a cache of seconds, so a client moved between cells is served by its new cell on its next request.
8.4 The analytics store
The owner proposed in the session a synchroniser to a shared database that is not critical, for analysis only, so that heavy queries never touch the reservation path. It is built as follows.
Each cell appends every change to its changes table with a sequence number (chapter 6). A job per client, running inside the cell, reads from the last delivered sequence, sends the batch to a bus and records the new position. The bus is Redis Streams with consumer groups, so that more than one reader can follow the same feed with its own cursor: the analytics loader, a notification service, the AI classifier the mobile app may one day want, a search index. On day one, with one cell, the analytics loader reads the changes table directly and the bus is absent. Each consumer upserts idempotently by tenant, table, key and sequence, so a duplicate or a reordered batch does no harm.
The store is one Postgres with every client in it, partitioned by time where the tables are large. It holds the reservations, listings and mappings in full; the ledger of attempts and conflicts, with the personal fields already tokenised in the cell; and the nights as a monthly aggregate of occupancy and average rate by default, with the full nights only if a report needs them.
Three properties make it safe. It is at-least-once everywhere, with idempotent writes, so the only question is lag and never loss. Its lag per client (the oldest undelivered sequence) is a metric with an alarm and a figure shown in the back office: "data as of 14:02:11". And for the period the cells retain, a rebuild of any client from its cell is a routine operation run by a tool. For history older than the cells' retention the analytics store is the only copy, and it is backed up as such; the sentence "a derived store that cannot be rebuilt is a second truth" therefore applies to the recent window, and the older window is named for what it is: the archive, with its own backups and its own retention.
8.5 The support tool reads both
Support's questions come in two shapes. "Which clients have an open circuit breaker right now?" is a question across clients and is answered from the analytics store, with its lag on screen. "Why does Booking show the 14th as taken for client X?" is a question about the truth and is answered from the client's cell, found through the directory. The tool routes by itself; the person asking does not need to know which store answered, but sees which one did. Its users are Hostify's staff, authenticated by Hostify's existing user system, and every view of a guest's details is an audit row.
9 Running it: visibility, security, failure and growth
9.1 Visibility
The ledger is the primary instrument. Because every event, intent and attempt is a row, the dashboard is a set of queries, and the same queries answer an incident and an audit. Above it, each cell emits telemetry from every process (the runtime's own instrumentation covers process counts, message queue lengths, memory, scheduler load), traces carry one id from the webhook at the edge through the allocation to the channel's response, and a live dashboard shows the tree of a client with the state of every leaf, so "why is room 12 not syncing" is a path to walk rather than a search.
The figures that define health, per channel and per cell:
| Figure | Target |
|---|---|
| Age of the oldest critical intent, sampled continuously | under five seconds with no burst and under thirty seconds during a burst, at the 99th percentile of samples |
| Time from a price change to the channel's acknowledgement | seconds on an ordinary day, under a minute at a peak, bounded by the channel's limit |
| Open circuit breakers | zero |
| Rows in conflicts, listings marked unsafe | zero |
| Dead intents, expired tokens | zero |
| Database connections, ledger write rate and CPU in use, per cell | each under a third of its ceiling |
| Analytics lag per client | minutes |
Alarms fire on ages and on the counts in the "bad" tables, never on raw throughput, because a quiet system and a stuck one both show low throughput and only the age tells them apart.
9.2 The time budget of a close
"Within seconds" is a sum, and the parts are stated so that the spike can measure each:
| Step | Budget | Basis |
|---|---|---|
| Channel to us | a webhook: milliseconds; Booking.com: up to twenty seconds, the polling cadence its documentation asks for | the channels' documentation |
| Edge: verify, resolve, write to the queue, acknowledge | under 100 ms | assumed; the platform's own figures are an average of about 2 ms of Worker time and a median queue send of about 60 ms, both the vendor's; measured in the spike |
| Queue to cell, append the event | under 200 ms | the cell's consumer polls the queue over the internet: a short poll interval plus a round trip to the nearest point of presence; measured in the spike |
| Allocation transaction with a synchronous replica | 10 to 50 ms | a short transaction over a local replica |
| Wake of the sender | immediate; the sweeper is the fallback every few seconds | chapter 6 |
| Limiter slot in the critical lane | immediate while the lane has capacity | the reserved share is not consumed by prices |
| The channel's call | half a second to several seconds | the XML endpoints of Booking.com and Expedia; Airbnb's calendar endpoint accepts at once and confirms later by webhook, so for Airbnb the budget ends at its acceptance and the confirmation's wait is counted by the intent's awaiting state |
| One retry after a transient error | the backoff, seconds |
A close therefore reaches a channel's acceptance in one to five seconds from our receipt of the booking when the critical lane is not saturated, and the lane is sized so that it is not: Booking.com's published table limits a partner to ten thousand calls a minute over all endpoints and gives its availability and rates endpoints no tighter row (quoted in the appendix), so a reserved critical share of thirty percent is three thousand closes a minute, and a burst of a thousand bookings in one minute drains in twenty seconds while a single booking waits for nothing. These are the figures the spike confirms or corrects; the summary quotes the target with its conditions.
9.3 Security
Every webhook is verified against the channel's secret before it is read. Channel credentials live in a vault, referenced by id from the channel accounts table, rotated by the token process in the tree, and never appear in a log or a row; the webhook secrets the edge needs live in the edge platform's secret store and are listed as the second home. Each role connects to Postgres with the least it needs: the queue drainer writes only inbound events, the analytics reader reads only the change feed, a cell cannot address another cell's database. Outbound calls go only to the hosts in the channels table. Every manual intervention and every view of a guest's details in the support tool is an audit row. Guest personal data is tokenised at write in the ledger and stays inside the cell; what reaches the analytics store carries tokens. Cells, the control plane and the analytics store run in the European Union; the edge uses the platform's regional option for the Worker, and where the edge's queue stores its messages is confirmed with the platform in writing before the first live client, as chapters 8 and 13 state.
9.4 What fails, and what the system does
| Failure | What happens | What a client sees |
|---|---|---|
| A process crashes on a bad response | its supervisor restarts it; it rehydrates from Postgres | nothing |
| A node of a cell dies, or is paused longer than its lease | the sibling takes the leases and starts the clients' trees from the database; the old node's writes fail on their stale tokens | a pause of seconds in sending; rehydration of a client is its open reservations plus its listings' intents, measured in the spike |
| A cell's Postgres fails over | the synchronous replica takes over with no acknowledged write lost; processes reconnect | a pause of the provider's published failover time, 30 to 120 seconds by the offerings researched |
| The synchronous replica is lost | commits halt; alarm; an operator degrades to asynchronous replication knowingly | a pause until the operator acts |
| The edge's queue is unavailable | the Worker hands each event to the cell's endpoint and acknowledges after the commit; if the cell is unreachable too the channel receives an error and retries by its policy; the reconciler's import covers what a channel gave up on | nothing while the cell answers; late events otherwise |
| The migration poller for Booking.com stops (chapter 10) | reservations stay in Booking.com's queue unacknowledged; after thirty minutes Booking.com e-mails the property; alarm on the poller's heartbeat | reservations arrive late, by e-mail if the pause is long |
| A channel is down or slow | its breaker opens, its lane pauses, the others continue; retries with backoff; alarm | that channel's prices lag; availability intents queue in the critical lane and are sent first when it returns |
| A channel refuses a close with a final answer | an immediate pull of its reservations; conflict if one overlaps; the listing unsafe; alarm with the reason | the listing is closed everywhere until a person clears it |
| A channel delivers the same reservation twice, or out of order | the inbound event is keyed by the channel's reservation id; the state machine reads the channel's current state and reconciles to it | nothing |
| A channel stops delivering reservations for hours (Airbnb's status page has recorded a window of a day) | the reconciler's import finds them; the gap is a drift row | reservations appear late; the gap is measured |
| The control plane is unreachable | cells use cached directory and static lane shares; the edge uses its cached index | prices may run slower |
| The analytics store is down | the change feed accumulates in the cells; lag alarm | reports go stale; nothing else |
| The hosting platform is down | nothing is sent or received; channels keep selling what we last sent | stale prices; channels may sell the same nights for the duration; on return the critical lane drains first and the reconciler runs once for every listing, and the conflicts it finds are reported against the outage length |
| Two channels sold the same nights before we could close them | the second allocation is refused by the constraint; the reservation goes to conflict; the listing is unsafe; alarm | a human decides which guest to move; the system did not choose |
The last row is the owner's third priority made concrete: the system prefers a visible conflict with a person at the end of it to a silent choice.
9.5 Backups and restore
Every Postgres, cell, control plane and analytics, has point-in-time recovery and a daily snapshot; the vault is backed up with them. The recovery point is the provider's and is stated per offering in the spike's report; the recovery time is measured by a restore drill per quarter into a scratch cell. A restored cell is behind the channels by the recovery gap, so before it may send, the reservation reconciler imports from every channel and the availability reconciler runs for every listing; the tool that restores enforces the order. Backups follow the retention table of chapter 6, which is what makes an erasure complete.
9.6 Headroom and growth
The coalescing of intents gives the system a built-in ceiling on sends: whatever the burst, they are bounded by listings times channels times kinds. Inbound events, night rows and ledger rows do scale with changes, and the database figures above are the measure of that. Each cell is provisioned to run at under a third of its ceiling on connections, ledger write rate and CPU in normal operation, so a peak of three times the average is absorbed without change. Under load the lanes defer in order: content first, rates second, availability and restrictions never; nothing is dropped, because intents coalesce.
Growth is more cells. The platform does not scale; it repeats. A cell whose database approaches its ceiling is split by moving clients, with a procedure: the client's leases are frozen and its in-flight intents drained; its rows are copied; the directory is flipped and the edge's cache for the client invalidated; events that still reach the old cell during the cache lifetime are written by its queue drainer to the new cell's edge queue, which is a call to the edge and not to a cell; the client's leases are released in the new cell, and the application's directory cache expires within seconds. The moved client pauses for minutes while its pending intents wait; nothing else notices.
9.7 Deployment
A new version rolls to one cell, then to a share of the cells, then to all; a cell that misbehaves is rolled back alone. Within a cell, a module whose state shape did not change is hot-loaded without a pause; otherwise the nodes are restarted one at a time and the lease protocol moves each client to the sibling for the duration. Schema migrations are written so that version n and n minus one of the code run against the same schema, which is what makes a rollback a configuration change.
10 What we ask of Hostify
Eight facts would turn numbers in this document from estimates into measurements. None changes the shape; each sizes a part.
| # | Question | What it decides |
|---|---|---|
| 1 | How many clients and listings does Hostify have today, how many channels per listing, and what does "a thousand operations a minute" count: inbound events from channels, outbound pushes, API calls in total? What is the peak against the average, and what causes it? | The size and the number of cells on day one, and the headroom figure |
| 2 | On each tier-one channel, will the new system be the same partner application and account as the legacy system, or a new one? | Whether hosts re-authorise, whether the edge fans out for all clients during the migration, and the whole shape of chapter 10 |
| 3 | The list of channels, each with its tier and the number of listings connected through it; which are iCal only, and how often those channels poll a feed | Which adapter the contract is checked against first, and the certification calendar |
| 4 | Where today's Postgres runs; whether the legacy system logs what it sends per listing and channel; and what fails most often: lost pushes, overbookings, lag, no visibility | What shadow mode diffs against, and what it is designed to catch |
| 5 | The largest client: listings, units, channels, reservations a day, and the shape of its price bursts | The ceiling the spike must measure, and whether that client is a cell of its own |
| 6 | For Airbnb, Booking.com and Expedia: do their writes carry an idempotency key, and are their webhooks ordered? The public documentation says no to both for Booking.com and Expedia and is behind a login for Airbnb | The adapter's retry rules and whether the inbound read-back of chapter 5 is needed on every event or only on disorder |
| 7 | Is there a public booking engine or availability search that reads the calendars at volume, and what is the property managers' application's read volume? | Whether a read replica per cell and a read cache in front of the cells are needed; the cache is the one place a Redis read cache would be justified |
| 8 | Does a pending request to book hold the nights on each channel today, and what does Hostify's support do with a conflict? | The hold policy per channel and the definition of the unsafe state's resolution |
10.1 The next step we recommend
A spike of one cell, as described in chapter 13, with its pass figures fixed before it runs, before any further design. Its result is a set of measurements and a running tree of processes under load, which is a better basis for the engine decision than any argument this document can make.
11 Appendix: the sources
Every figure and platform fact in this document traces to a line below. A vendor's statement about its own product is marked "claims". Documentation pages without a publication date are given with the date they were read, 12 October 2026. The five research reports that gathered these are kept verbatim in the project's raw sources.
11.1 The channels (chapters 5, 7, 9)
| Fact | Source |
|---|---|
Booking.com rate-limit table, per provider account per minute, verbatim: "All endpoints 10000"; /xml/reservationssummary 700; /ota/OTA_HotelDescriptiveContentNotif 75; /ota/OTA_HotelInvNotif 75; /ota/OTA_HotelProductNotif 75; /ota/OTA_HotelSummaryNotif 75; /ota/OTA_HotelDescriptiveInfo 100; "All RtB API endpoints" 200. OTA_HotelAvailNotif and OTA_HotelRateAmountNotif have no row and fall under "All endpoints" | https://developers.booking.com/connectivity/docs (section "Rate limiting", read 2026-10-12) |
Airbnb reads a reservation by its confirmation code (GET /v2/reservations/{confirmation_code}, named in an incident of 2026-05-18) | https://airbnbapi.statuspage.io (incident feed read 2026-10-12) |
| Airbnb partner rating criteria: API success rate, feature coverage, host tool access | https://www.airbnb.com/d/software-partners and https://hotelrunner.com/en/news/corporate/hotelrunner-is-once-again-titled-as-preferred-software-partner-by-airbnb/ (read 2026-10-12) |
| Expedia Elite partners' own figures: Cloudbeds 99.7 % API success (2025-04-01); HotelRunner 99.9 % availability-and-rates and 99.5 % booking success (2026-02-19); both the partners' claims of Expedia-measured numbers | https://www.cloudbeds.com/articles/expedia-elite-status and https://hoteltechreport.com/news/hotelrunner-recognized-with-expedia-groups-highest-elite |
| Booking.com reservations are pulled: poll every 20 seconds, acknowledge, unacknowledged reservations are redelivered, e-mail fallback after 30 minutes | https://developers.booking.com/connectivity/docs/reservations-api/reservations-process-ota and https://developers.booking.com/connectivity/docs/reservations-api/retrieving-new-reservations-ota (read 2026-10-12) |
| Booking.com rates and availability: one hotel and one month per request; send changes, not full refreshes; at least twelve months of availability | https://developers.booking.com/connectivity/docs/ota-hotelavailnotif (read 2026-10-12) |
| Booking.com's own causes of overbooking, including automatic re-release of cancelled nights | https://developers.booking.com/connectivity/docs/con-faq-reservations-troubleshooting-overbookings (read 2026-10-12) |
| Booking.com partner programme minimum requirements: under 5 % reservation fallbacks, under 10 % rates-and-availability error rate | https://connectivity.booking.com/s/partnerprogramme/minimum-requirements (indexed text; the page renders only with scripts) |
| Booking.com iCal every two hours and unavailable beside a connectivity partner | https://partner.booking.com/en-us/help/rates-availability/extranet-calendar/how-synchronize-your-calendars-across-channels (read 2026-10-12) |
| Airbnb documentation behind a partner login; limits at the channel's discretion | https://www.airbnb.com/terms/api (2025-10-15) and https://developer.withairbnb.com/ (redirects to login, 2026-10-12) |
| Airbnb webhooks delayed, missed or duplicated; a day-long gap; calendar delays of 45 minutes to 2 hours for high-volume partners (June 2026) | https://airbnbapi.statuspage.io (incident feed read 2026-10-12) and https://pingoru.io/providers/airbnb/outage-history (third party, read 2026-10-12) |
| Airbnb iCal imports every three hours | https://www.airbnb.com/help/article/99 (read 2026-10-12) |
| Airbnb request to book: the host has 24 hours | https://www.airbnb.com/help/article/28 (read 2026-10-12) |
| Expedia: exponential backoff on 429; about 180 updates per product per day; up to 5,000 updates per message; booking notifications retried with exponential backoff for up to about two and a half hours, then e-mail; signed webhooks with a yearly secret; a booking retrieval API for reading reservations | https://developers.expediagroup.com/supply/lodging/docs (indexed text; every page redirected to https://connectivityportal.expediagroup.com/documentation/expedia on 2026-10-12, so these facts rest on the search index of the former pages) |
Booking.com reads of reservations: the reservations API, GET OTA_HotelResNotif and the reservations summary endpoint | https://developers.booking.com/connectivity/docs/reservations-api/reservations-overview (read 2026-10-12) |
| Expedia partner programme: metric groups without published thresholds | https://developers.expediagroup.com/supply/lodging/cpp/overview (read 2026-10-12) |
| Vrbo iCal every 30 minutes; a decline after 24 hours counts as a cancellation | https://help.vrbo.com/articles/How-do-I-import-my-iCal-or-Google-calendar and https://help.vrbo.com/articles/IPM-About-booking-requests-for-integrated-accounts (read 2026-10-12) |
| Hostify: Booking.com Premier Plus 2026 | https://hostify.com/blog/booking-com-partner-premier-plus-hostify (2026-04-23) |
| The one vendor post-mortem found: a token-refresh race stopped Booking.com sync (Resly, 2026-07-15) | https://support.resly.com.au/en/articles/16029549-service-incident-booking-com-synchronisation-interruption |
11.2 The market (chapters 5, 11)
| Fact | Source |
|---|---|
| SiteMinder: Kinesis, "asynchronous ... learnt the hard way" (CTO interview, 2022-07-05) | https://bailador.craft5.t-bone.com.au/news/interview-with-siteminders-co-founder-cto-mike-rogers |
| Lodgify: RabbitMQ outbox and inbox, Kafka fan-out to channel managers, SQL Server to Postgres with zero downtime (job advertisement, October 2026) | https://www.workingnomads.com/jobs/senior-backend-engineer-lodgify-1919534 |
| Guesty: webhook-first, 15+ calls a second (claims, 2025-12-04); Airbnb sync delays "due to high traffic volume", mitigated by "scaling down low-priority tasks" (2026-07-27) | https://www.guesty.com/blog/channel-manager-quality-real-time-sync/ and https://help.guesty.com/hc/en-gb/articles/37934073247389-Resolved-Airbnb-synchronization-is-experiencing-delays-2026-07-27 (indexed text) |
| Hostaway: SQS, PHP and Go (job advertisement, 2026) | https://www.workingnomads.com/jobs/staff-backend-engineer-php-golang-hostaway |
| Guesty: Kafka, RabbitMQ, Node.js, Postgres and MongoDB (job advertisements) | https://jobify360.co.il/jobs/4277307828-in and https://careers.viola-group.com/companies/guesty/jobs/44426036-senior-backend-engineer |
| Cloudbeds: Kafka, PHP, a move to event-driven services (job advertisements, 2026) | https://www.builtincolorado.com/job/senior-backend-engineer/10157416 and https://www.wearedevelopers.com/en/jobs/ext/6457429/fullstack-engineer |
| eviivo: RabbitMQ, Redis, C# (careers page, indexed text) | https://eviivo.com/careers/overview-software-engineer |
| D-EDGE: F# and MSMQ channel manager (job advertisement) | https://www.welcometothejungle.com/en/companies/d-edge/jobs/back-end-software-engineer-c-f-channel-manager-experience-m-f-nb (indexed text) |
| RateGain: 231 billion updates a year (claims, 2026-03-17); prioritisation by days to check-in, 30 to 40 % less traffic (claims, 2026-03-06) | https://nsearchives.nseindia.com/corporate/RATEGAIN_06032026111209_RG_PR_06032026.pdf |
| Channel sync intervals and the double-booking window as one channel manager states them (2026-08-14) | https://support.airhost.co/en/articles/8693057-channel-synchronization-speed-interval-with-each-ota-and-the-risk-of-double-booking-overlapped-booking |
11.3 Postgres and the data layer (chapter 6)
| Fact | Source |
|---|---|
Exclusion constraints over ranges; GiST; btree_gist | https://www.postgresql.org/docs/current/rangetypes.html and https://www.postgresql.org/docs/current/btree-gist.html (read 2026-10-12) |
| Enforcement like a unique index: wait for the in-progress transaction, then recheck | https://www.postgresql.org/docs/current/index-unique-checks.html (read 2026-10-12) |
| Overlap rejection at READ COMMITTED without SERIALIZABLE; errors 23P01 and 40P01 | https://dev.to/franckpachot/postgresql-exclude-constraints-for-better-concurrency-than-serializable-pob (2025-05-15) |
Postgres 18 WITHOUT OVERLAPS | https://www.postgresql.org/docs/18/release-18.html (2025-09-25) |
| SQLite has only PRIMARY KEY, UNIQUE, CHECK and FOREIGN KEY constraints | https://www.sqlite.org/lang_createtable.html (read 2026-10-12) |
SKIP LOCKED is for queue-like tables | https://www.postgresql.org/docs/current/sql-select.html (read 2026-10-12) |
| River about 46,000 jobs a second on an eight-core 2022 MacBook Air (vendor benchmark, with its own warning against comparison) | https://riverqueue.com/docs/benchmarks (read 2026-10-12) |
| Oban 17,699 jobs a second on one queue, about 32,000 overall (vendor benchmark, 2022-06-06) | https://oban.pro/articles/one-million-jobs-a-minute-with-oban |
| graphile-worker about 15,600 unbatched, 183,000 batched (vendor benchmark) | https://worker.graphile.org/docs/performance (read 2026-10-12) |
| Hazards of Postgres as a queue: dead tuples, the pending index, long transactions pinning the vacuum | https://richyen.com/postgres/2026/05/04/postgres_job_queue.html (2026-05-04) and https://stormatics.tech/blogs/long-running-transactions-job-queues-and-the-cascade-that-wreaks-havoc (2026-05-22) |
| The transactional outbox and its relays | https://microservices.io/patterns/data/transactional-outbox.html (read 2026-10-12) and https://debezium.io/blog/2019/02/19/reliable-microservices-data-exchange-with-the-outbox-pattern/ (2019-02-19) |
| Idempotent consumers and idempotency keys | https://microservices.io/patterns/communication-style/idempotent-consumer.html and https://docs.stripe.com/api/idempotent_requests (read 2026-10-12) |
| RDS Multi-AZ: synchronous standby, failover 60 to 120 seconds | https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZSingleStandby.html and https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.MultiAZ.Failover.html (read 2026-10-12) |
| Aurora: six copies across three zones, "without any loss of data", under 60 seconds with a reader | https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Concepts.AuroraHighAvailability.html (read 2026-10-12) |
| Cloud SQL: synchronous regional disk, about 60 seconds | https://docs.cloud.google.com/sql/docs/postgres/high-availability (read 2026-10-12) |
| AlloyDB: "No data loss occurs during failover" | https://docs.cloud.google.com/alloydb/docs/high-availability (read 2026-10-12) |
| PlanetScale Postgres: commit confirmed by at least one replica | https://planetscale.com/docs/postgres/scaling/replicas (read 2026-10-12) |
| Neon: commit on a Paxos quorum of safekeepers; recovery times (claims); now Databricks | https://neon.com/docs/introduction/architecture-overview and https://neon.com/docs/introduction/high-availability (read 2026-10-12) |
| Crunchy Bridge: hidden hot standby, synchronous or asynchronous not stated | https://docs.crunchybridge.com/concepts/high-availability (read 2026-10-12) |
| Supabase: single node by default, failover on the Enterprise plan, PITR two-minute RPO | https://supabase.com/docs/guides/platform/read-replicas and https://supabase.com/docs/guides/platform/backups (read 2026-10-12) |
11.4 Cells and sharding (chapter 4)
| Fact | Source |
|---|---|
| AWS cell-based architecture: no shared state, thinnest router, control plane may fail, size for the largest customer, full mapping | https://docs.aws.amazon.com/wellarchitected/latest/reducing-scope-of-impact-with-cell-based-architecture/reducing-scope-of-impact-with-cell-based-architecture.html (2023-09-20) |
| Directory versus hash sharding; schema and backup costs per shard | https://learn.microsoft.com/en-us/azure/architecture/patterns/sharding (2026-04-02) |
| Database-per-tenant costs: a catalog, schema management at scale | https://learn.microsoft.com/en-us/azure/azure-sql/database/saas-tenancy-app-design-patterns (2025-08-21) |
| Citus: distribution column in every key; single-shard limits | https://docs.citusdata.com/en/stable/use_cases/multi_tenant.html (read 2026-10-12) |
11.5 Erlang/OTP and Elixir (chapters 5, 9, 11)
| Fact | Source |
|---|---|
| A process is 338 words when spawned, heap of 233 words | https://www.erlang.org/doc/system/memory.html (OTP 29.1.1, read 2026-10-12) |
| Process limit default 1,048,576, maximum 134,217,727; per-process heap limits | https://www.erlang.org/doc/apps/erts/erl_cmd.html and https://www.erlang.org/doc/apps/erts/erlang.html#process_flag/2 (read 2026-10-12) |
| Pre-emption after 4,000 reductions | https://raw.githubusercontent.com/erlang/otp/master/erts/emulator/beam/erl_vm.h (read 2026-10-12) |
| Garbage collection of a large heap and long native calls are not pre-empted | https://erlang.org/pipermail/erlang-questions/2014-October/081521.html and https://discord.com/blog/using-rust-to-scale-elixir-for-11-million-concurrent-users (2019-05-17) |
gen_statem: state timeouts, code_change | https://www.erlang.org/doc/apps/stdlib/gen_statem.html (read 2026-10-12) |
| Hot upgrades "not supported out of the box" by Elixir releases; rolling deploys recommended | https://hexdocs.pm/mix/Mix.Tasks.Release.html (read 2026-10-12) |
| WhatsApp: hot patching "is rare"; 465 million users, about 10 engineers (claims, 2014-03-31) | https://highscalability.com/how-whatsapp-grew-to-nearly-500-million-users-11000-cores-an/ |
| Discord: 12 million concurrent users, 26 million events a second; left Mnesia after production failures (claims, 2020-10-08) | https://elixir-lang.org/blog/2020/10/08/real-time-communication-at-scale-with-elixir-at-discord/ |
| Klarna left Mnesia after 16 years and a 15 TB database (talk abstract, 2021-05-20) | https://www.codesync.global/speaker/onno-vos (indexed text) |
| ETS tables die with their owner | https://www.erlang.org/doc/apps/stdlib/ets.html (read 2026-10-12) |
| DETS files cannot exceed 2 GB | https://www.erlang.org/doc/apps/stdlib/dets.html (read 2026-10-12) |
| Mnesia: a partitioned network is reported and its resolution "is outside the scope of Mnesia" | https://www.erlang.org/doc/apps/mnesia/mnesia_chap7.html (read 2026-10-12) |
| RabbitMQ removed Mnesia (4.3); Khepri default from 4.2 | https://www.rabbitmq.com/docs/partitions and https://www.rabbitmq.com/blog/2025/09/01/6-khepri-default (2025-09-01) |
| Horde: duplicates may exist until a partition heals | https://hexdocs.pm/horde/eventual_consistency.html (read 2026-10-12) |
| Ecto turns an exclusion violation into a changeset error | https://hexdocs.pm/ecto/Ecto.Changeset.html#exclusion_constraint/3 (read 2026-10-12) |
| Oban: transactional enqueue, unique jobs | https://hexdocs.pm/oban/Oban.html (read 2026-10-12) |
| Broadway back-pressure; a community Redis Streams producer | https://hexdocs.pm/broadway/Broadway.html and https://github.com/akash-akya/off_broadway_redis_stream (read 2026-10-12) |
| LiteFS: single primary, a documented data-loss hazard under autostop | https://fly.io/docs/litefs/ (read 2026-10-12) |
| Observability: telemetry, LiveDashboard, Observer, recon, OpenTelemetry | https://hexdocs.pm/telemetry/readme.html and https://hexdocs.pm/phoenix_live_dashboard/Phoenix.LiveDashboard.html and https://opentelemetry.io/docs/languages/erlang/ (read 2026-10-12) |
11.6 Cloudflare (chapters 8, 11)
| Fact | Source |
|---|---|
| Workers: CPU 30 seconds by default, up to 5 minutes; 6 simultaneous connections; 128 MB; "the average Worker uses approximately 2.2 ms per request" (claims) | https://developers.cloudflare.com/workers/platform/limits/ (read 2026-10-12) |
| Queues: median send latency about 60 ms after the 2024 redesign (claims, vendor-measured) | https://blog.cloudflare.com/how-we-built-cloudflare-queues/ (2024-10-24) |
| Durable Objects: single-threaded; about 500 to 1,000 requests a second per object; placed where first requested and not moved; EU jurisdiction; one alarm | https://developers.cloudflare.com/durable-objects/best-practices/rules-of-durable-objects/ and https://developers.cloudflare.com/durable-objects/reference/data-location/ and https://developers.cloudflare.com/durable-objects/api/alarms/ (read 2026-10-12) |
| Durable Objects storage: a write confirmed by three of five followers (vendor description, 2024-09-26) | https://blog.cloudflare.com/sqlite-in-durable-objects/ |
| D1: 10 GB per database, not raisable; 50,000 databases; single-threaded; per-tenant databases recommended | https://developers.cloudflare.com/d1/platform/limits/ and https://developers.cloudflare.com/d1/reference/faq/ (read 2026-10-12) |
D1 batches are atomic; BEGIN is rejected; the feature request closed "not planned" (2025-10-07) | https://developers.cloudflare.com/d1/worker-api/d1-database/ and https://github.com/cloudflare/workers-sdk/issues/2733 |
| Queues: at least once; no ordering guarantee; dead-letter queues | https://developers.cloudflare.com/queues/reference/delivery-guarantees/ and https://developers.cloudflare.com/queues/reference/how-queues-works/ (read 2026-10-12) |
| Workflows generally available (2025-04-07) | https://developers.cloudflare.com/changelog/post/2025-04-07-workflows-ga/ |
| Hyperdrive: transaction-mode pooling; cached reads not invalidated on write | https://developers.cloudflare.com/hyperdrive/concepts/how-hyperdrive-works/ and https://developers.cloudflare.com/hyperdrive/reference/faq/ (read 2026-10-12) |
| The rate-limit binding is per location and "intentionally designed to not be used as an accurate accounting system" | https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/ (read 2026-10-12) |
| Incidents: 2025-06-12 (Durable Objects, D1 and Queues affected together), 2025-11-18, 2025-12-05 | https://blog.cloudflare.com/cloudflare-service-outage-june-12-2025/ and https://blog.cloudflare.com/18-november-2025-outage/ and https://blog.cloudflare.com/5-december-2025-outage/ |
11.7 Redis (chapters 8, 11)
| Fact | Source |
|---|---|
Persistence: AOF every second "may lose 1 second"; always is "very very slow, very safe" | https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/ (read 2026-10-12) |
Replication is asynchronous; WAIT "does not make Redis a strongly consistent store" | https://redis.io/docs/latest/operate/oss_and_stack/management/replication/ and https://redis.io/docs/latest/commands/wait/ (read 2026-10-12) |
| Sentinel: "eventually consistent ... last failover wins"; Cluster "does not guarantee strong consistency" | https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/ and https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/ (read 2026-10-12) |
| Streams: consumer groups, at least once, the dead-letter pattern | https://redis.io/docs/latest/develop/data-types/streams/ (read 2026-10-12) |
| Lua scripts are atomic; keys in one hash slot | https://redis.io/docs/latest/develop/programmability/eval-intro/ (read 2026-10-12) |
| Token bucket in Lua | https://redis.io/docs/latest/develop/use-cases/rate-limiter/ (read 2026-10-12) |
12 Appendix: the review's findings, one by one
Severity as the reviewer gave it. "Fixed" names the chapter that changed; "Declined" names the reason.
| # | Severity | Finding | Disposition |
|---|---|---|---|
| 1 | Critical | The exclusion constraint guarded the listing; the overlaps that matter are per physical unit | Fixed: occupancy table per unit, chapters 3 and 6 |
| 2 | Critical | No deduplication before allocation; a retried webhook became a false conflict | Fixed: routing by the channel's reservation id, chapter 5 |
| 3 | Critical | The edge acknowledged before anything durable existed; three chapters disagreed | Fixed: durable queue, acknowledgement after the write, chapters 4, 8, 9 |
| 4 | High | Out-of-order inbound events were answered with an outbound argument | Fixed: read-back from the channel, held cancellations, chapter 5 |
| 5 | High | A unique row does not stop two nodes from sending; no fencing | Fixed: lease with a fencing token on every statement, chapters 5, 6 |
| 6 | High | The dirty flag had a lost-update window; the version column was unexplained | Fixed: two conditional statements; column removed, chapter 6 |
| 7 | High | "No data in the intent" contradicted "one bulk call for the changed dates" | Fixed: key and scope, never a value, chapters 3, 6 |
| 8 | High | "Within seconds" had no budget; the shared partner limit is the real bound | Fixed: budget table and drain calculation, chapter 9 |
| 9 | High | The global limiter broke "a hot listing slows nothing else" and cross-cell fairness | Fixed: limiter lanes per cell, chapters 3, 4, 8 |
| 10 | High | A channel refusing a close was not handled | Fixed: pull, conflict, unsafe, alarm, chapters 3, 5, 9 |
| 11 | High | "Never an overbooking" during a platform outage contradicted the summary | Fixed: the window is the outage, chapters 1, 4, 9 |
| 12 | High | The reconciler's direction was undefined | Fixed: import reservations, push availability, chapter 7 |
| 13 | High | Shadow mode did not say where its inputs came from; chapters 10 and 91 contradicted each other on reading the legacy database | Fixed: three inputs and their mechanisms; shadow reads the legacy database read-only, chapter 10 |
| 14 | High | Cutover ignored the channel-side binding, the dual-send window and the limit of rollback | Fixed: binding decision, fan-out, window, horizon with back-fill, chapter 10; ask 2 |
| 15 | High | The API Hostify's own application uses was absent | Fixed: a component of the cell, chapter 8 |
| 16 | High | Personal data contradicted "pseudonymised" and "permanent archive"; no retention or erasure | Fixed: tokens at write, retention table, erasure, archive named, chapters 6, 8, 9 |
| 17 | High | "Not complex" was overstated; the parts were nine kinds, not three | Fixed: inventory table and a day-one deployment of one cell, chapter 4 |
| 18 | Medium | Range semantics unstated: half-open, partial, alteration, btree_gist | Fixed: chapter 6 |
| 19 | Medium | Manual blocks and owner stays were invisible to the constraint; availability had two homes | Fixed: blocks and holds in the occupancy table; availability derived, chapters 3, 6 |
| 20 | Medium | Time zones, DST and the definition of a night were absent | Fixed: chapter 6 |
| 21 | Medium | What "unsafe" does was undefined | Fixed: closes every night everywhere until cleared, chapters 3, 5, glossary |
| 22 | Medium | Requests to book and holds had no policy | Fixed: hold kind with expiry, chapters 5, 6; ask 8 |
| 23 | Medium | "A transition nobody thought of is a compile error" is not true in Elixir | Fixed: a crash caught by the supervisor and a state-by-event test, chapter 5 |
| 24 | Medium | The fairness argument was unfair to other runtimes and aimed at the wrong resource | Fixed: scheduler as convenience, per-client caps as the mechanism, chapter 5 |
| 25 | Medium | Failover numbers and the synchronous replica's liveness cost were not stated | Fixed: providers' figures and the halt-on-replica-loss policy, chapters 6, 9 |
| 26 | Medium | Numbers rested on an appendix and a review that did not exist | Fixed: appendix of sources; review chapter; summary rewritten |
| 27 | Medium | Who writes inbound events, and whether the engine is decided, were stated three ways | Fixed: the queue drainer writes; recommended subject to the spike's pass figures, chapters 1, 13 |
| 28 | Medium | The alternatives chapter was unfair in four cells; hiring and ecosystem costs omitted; provider claim overstated | Fixed in the four cells and the ecosystem cost; portability qualified, chapter 11. Declined for hiring: no staffing content by the owner's instruction |
| 29 | Medium | The pricing model and currencies were missing | Fixed: chapter 6 |
| 30 | Medium | Restrictions had no safety classification; ordering between kinds was unexamined | Fixed: restrictions in the critical lane; declared order of kinds, chapters 3, 7 |
| 31 | Medium | Eight internal inconsistencies | Fixed, each in its chapter |
| 32 | Medium | The analytics store was "the permanent archive" and also "rebuilt from the cells" | Fixed: the archive named for the older window with its own backups, chapter 8 |
| 33 | Medium | Moving a client between cells had no protocol | Fixed: numbered procedure, chapter 9 |
| 34 | Medium | Backups were a table cell, not a procedure | Fixed: chapter 9 |
| 35 | Medium | Edge-to-cell transport, webhook secrets and EU residency were not reconciled | Fixed: regional option, edge secret store, queue pull, support access, chapters 8, 9 |
| 36 | Medium | The invariants themselves were untested | Fixed: a test per invariant, chapter 13 |
| 37 | Medium | Cost, team and timeline were absent | Declined: outside this document by the owner's instruction; recorded in chapter 12 |
| 38 | Low | Eight wording items | Fixed, each in its chapter |
12.1 The second round
| # | Severity | Finding | Disposition |
|---|---|---|---|
| N1 | High | Booking.com's reservation queue is one per partner; a per-client flag cannot partition it; shadow mode saw a sample | Fixed: one partner poller for the migration, chapter 10; parts and failure tables |
| N2 | High | A refused open or a missing price marked the listing unsafe everywhere | Fixed: the taxonomy split by direction, chapters 3, 5 |
| N3 | High | The drain figure rested on an unquoted reading of the rate-limit table | Fixed: the table quoted with endpoint names, chapters 7, 9, appendix |
| N4 | High | The edge queue was a single Cloudflare dependency, denied in chapter 11; its residency unsourced | Fixed: second receiver, risk rows, chapter 11 rewritten, residency stated as unconfirmed, chapters 1, 4, 8, 9, 11, 13 |
| N5 | Medium | The parts count was not applied to the conventional design | Fixed: chapter 11 row with chapter 4's rows |
| N6 | Medium | "Queue to cell: local network" was false for an edge queue | Fixed: chapter 9 |
| N7 | Medium | The edge row's basis was not in the appendix | Fixed: appendix rows for the Worker and queue figures |
| N8 | Medium | Airbnb keeps a request's dates blocked; chapter 6 said a hold always releases | Fixed: a per-channel capability, chapters 3, 5, 6, 7 |
| N9 | Medium | Eight new statements without a source | Fixed: sourced in the appendix or removed (Airbnb Preferred+) |
| N10 | Medium | Airbnb's asynchronous endpoint had no awaiting state | Fixed: chapters 5, 6, 9 |
| N11 | Medium | Booking.com's polling cadence was outside the budget | Fixed: a first row, chapter 9; the summary qualified |
| N12 | Medium | The glossary contradicted the chapters | Fixed: glossary revised |
| N13 | Low | The client move had a cell calling a cell | Fixed: written to the new cell's queue, chapter 9 |
| N14 | Low | Small inaccuracies in the review chapter and the brief; sessions pinned to the old cell | Fixed: chapters 2, 3, 6, 8, 12 |
| N15 | Low | The target figure in three framings | Fixed: the number in the health table, chapter 9 |
13 Appendix: glossary
| Term | Meaning in this document |
|---|---|
| Channel | An online travel agency or another site where a property is sold: Airbnb, Booking.com, Expedia, Vrbo, an iCal feed |
| Channel manager | The system that keeps a property's availability, prices and reservations identical on every channel |
| Client, tenant | A Hostify customer: a property manager with one or many properties |
| Listing | One sellable unit as a channel sees it; a property may have several listings, and one physical unit may be sold under several linked listings |
| Property | A group of units whose nights must be allocated together, with one time zone |
| Unit | One physical space that one party can occupy for a night; the key of the overlap guard. A listing may sell one unit, several units, or part of a group of units |
| Occupancy | A row that holds a unit for a range of nights: a reservation, a block (maintenance, owner stay) or a hold (a pending request) |
| Hold | An occupancy for a pending request to book; when the request expires it is released by its own timer or kept until the channel releases it, as the channel's adapter declares |
| Unsafe | The state of a listing after a conflict or a refused close: every night of its units is closed on every channel until a person clears it |
| Lease, fencing token | The record of which node of a cell runs a client, with a token that changes on every takeover; a statement from a node whose token is stale affects no row |
| Scope | The merged set of date ranges an intent covers; the intent carries a scope and never a value |
| Night | The unit of the calendar: one listing, one date, with its availability, price and restrictions |
| Overbooking | Two reservations holding the same night of the same unit |
| Cell | A bounded group of clients with its own compute and database; nothing in a cell depends on another cell |
| Control plane | The small shared set of facts every cell needs: which client is in which cell, the channels' configuration, the global rate limiters |
| Edge | Cloudflare in front of every webhook endpoint: firewall, rate limiting, signature check, a durable queue per cell written before the channel is answered, and the cell's own endpoint as the second receiver |
| Queue | The durable store at the edge that holds a webhook until the cell has read it; an outage of it is bounded by the second receiver |
| Kind | The sort of data an intent carries: availability, restrictions, rates, content |
| Intent | A record that a key must be synchronised to a channel; it names the key and never carries the data |
| Key | Tenant, listing, channel and kind of data; the unit of ordering |
| Lane | The priority class of an intent: critical (availability and restrictions), bulk (rates), content |
| Ledger | The four tables that record everything: inbound events, intents, attempts, manual interventions |
| Outbox | The pattern of writing the work to propagate a change in the same transaction as the change |
| Process, actor | A lightweight unit of execution on the Erlang virtual machine with its own memory and message queue; the system runs one per client, property, listing, listing-channel pair and reservation |
| Supervisor, supervision tree | A process whose job is to restart its children when they fail; the tree of supervisors is the recovery model |
| State machine | A component with named states and named transitions; the reservation is one |
| Exclusion constraint | A Postgres rule that refuses two rows whose ranges overlap on the same key; here, two occupancies of one unit on the same nights |
| Synchronous replica | A second database that must confirm every write before the first acknowledges it; a failover to it loses nothing |
| Circuit breaker | A guard that pauses calls to a channel whose errors climb and tests it again after a cooling period |
| Rate limit | The number of calls a channel accepts per unit of time, often per partner account |
| Idempotent | An operation that can be repeated without changing the result; the property that makes at-least-once delivery safe |
| Reconciliation | A periodic full comparison with the channel, in two directions: reservations are imported from the channel, availability and rates are pushed from ours |
| Shadow mode | Running the new system on a client's real traffic without sending, and comparing the night state it would have on each channel with the channel's actual view |
| Change feed | The sequence of changes a cell publishes for the analytics store |
| Analytics store | The derived, shared database for reports and cross-client questions; never on the path of a reservation |
| Spike | A short, measured build of the core without channel adapters, to answer the open numbers |