Hostify channel sync: the architecture of the rewrite Contents

Hostify

Channel synchronisation: the architecture of the rewrite

Cells, a tree of processes on the Erlang VM, one Postgres truth per cell, and what the alternatives trade; with sources and an independent review

October 2026

Laid out for A4 as well: Print, "Save as PDF", paper A4, background graphics on.

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

PriorityHow the design meets it
Not complexFive 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 horizontallyGrowth is more cells; the platform repeats itself rather than growing
Fails rather than overbooksThe 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 fastA 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 peakEach 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:

  1. Not complex.
  2. Scales horizontally with ease.
  3. Fails rather than makes an overlapping reservation.
  4. A price change reaches the channels as fast as possible.
  5. 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.

ChannelsAirbnb, Booking.com, Expedia, 20+Cloudflare edgefirewall, signature, queueCell k (one of N)Erlang nodesa tree of processes per clientPostgres + sync replicaoccupancy, ledger, APIApplication APIProperty managers' appweb, mobileControl planedirectory, channels, limiterAnalytics storederived Postgreswebhooksdurable queuepushes and pulls, within the limitsreads and writesfacts, tokenschange feed
Figure 1. The system: channels reach the edge, which writes every webhook to a durable queue before answering; a cell holds a group of clients with its own nodes, its own Postgres and the application API; the control plane is cached inside every cell; the analytics store is written after the fact and never read by a cell.

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 ofWhat it means in a cell architecture
IsolationA failed node, a bad deploy, a poisoned input or a runaway query affects the clients of one cell
ScalingMore clients means more cells; the platform does not grow, it repeats
DeploymentA new version goes to one cell, then to a share of cells, then to all; a cell with a problem is rolled back alone
MigrationA client moves from the legacy system in three states, legacy, shadow and live, one client at a time
TestingA synthetic client in a production cell takes the chaos tests; nothing real is at risk
Backup and restorePer cell, small and fast; a restore never touches another cell's clients
SupportThe 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.

PartKindDay one, one cellAt several cells
Erlang nodes running the cellcomputetwo nodestwo or three per cell
Postgres with a synchronous replicadatabaseone, holding the cell and the control-plane schemaone per cell, plus one for the control plane
Cloudflare edge: firewall, Worker, Queueedgeyesyes
Redis: cache and limitercacheabsent; the static shares are the limiterone, replicated
Redis Streams: the bus to derived consumersbusabsent; the analytics reader polls the change feedone
Analytics Postgresdatabasea schema in the same Postgres, or absentone
Secrets vaultsecurityyesyes
Tools: shadow diff, client move, supportsoftwareshadow diff and support; client move at the second cellall three
Migration poller for Booking.com's reservation queue (chapter 10)softwarefor the whole migration, then retiredretired

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

Client supervisorChannel account ร— ntoken refresh, alarmProperty supervisor ร— pPropertythe only allocator of nightsListing supervisorReservation supervisorListing ร— mper listingReservation ร— rgen_statem of the lifecycleListing-channel ร— kgen_statem of the sendingallocate / releasenotify: nights changedcredentials
Figure 2. The supervision tree of one client, five levels deep: the property process alone allocates nights and the reservation state machines only ask it; a channel account is a sibling of the properties because all of them share it; the listing-channel state machines own the sending. Dashed arrows are messages, solid lines are supervision.

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

receivedallocatedconfirmedchecked inchecked outarchivednights takenchannel confirmedarrivaldeparturedays lateraltered: allocate the new nightsconflictnights refusedrejected, expiredtimercancelledrelease nights
Figure 3. The reservation as a state machine: every transition is an event with a condition and a side effect, the timers live inside the state, and conflict is a state the application shows, not an error in a log. An instant-booking channel has already confirmed when the event arrives; the allocation is the system's only act.

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

Channel AEdgeCell inboundReservationPropertyPostgresSenderChannel B1 webhook1 write to the durable queue1 200, after the write2โ€“3 append event; route by external id4 allocate nights5 one transaction: occupancy, intents, feed5 committed, or refused by the constraint6 allocated โ†’ confirmed6 wake the other channels' senders7 claim the intent; read current nights7 close the nights, within the limit7 acknowledged7 finish the intent (or back to pending if dirty)The overbooking window is from step 5 to the last acknowledgement in step 7; its age is the first metric.
Figure 4. A booking from channel A, step by step: the edge answers only after the durable write; the event is routed by the channel's own reservation id before anything else; one transaction allocates the nights and writes the intents; the senders close the nights on channel B under the critical lane of the limiter.
  1. 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.)
  2. 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.
  3. 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.
  4. The reservation asks its property to allocate the nights.
  5. 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.
  6. 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.
  7. 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.
  8. 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_gist extension 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 OVERLAPS clause, 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:

TableWhat it holdsNotes
property, unit, listing, channel_mappingthe physical units, the listings sold on channels and their external idsa property's time zone is a column
occupancyone row per unit and occupying range, with kind and statethe exclusion constraint lives here
nightavailability, price by occupancy, restrictions, minimum stay per listing and nightavailability derived from occupancy
rate_plan, price_rulebase prices and the rules derived from themone change here is one transaction across its listings
reservationthe reservation as the channel knows it, with the channel's own idunique on tenant, channel and external id
reservation_eventsevery transition of every reservation, in orderthe history the state machine is rebuilt from
conflictsoccupancies that could not be allocatedan alarm, never a deletion
inbound_eventsevery webhook and poll result as received, personal fields tokenisedunique 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_intentsone row per key with lane, state, scope, dirty flag and the claiming node's tokenthe outbox
sync_attemptsevery send: request, response, latency, resultpartitioned by month; personal fields tokenised at write
changessequence, table, key, operation, timethe feed the analytics store reads
leasewhich node of the cell owns which client, with a token and a heartbeatthe ownership protocol of chapter 5
audit_logevery 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

pendingscope: changed rangesin flightowned by one senderacknowledgedkept for the ledgerawaiting confirmationasynchronous channelswebhook, or timeoutfailed (n)next attempt at tdeadan alarm, never a deletionclaim: one conditional update, lease tokenfinish, not dirtyfinish, dirty: back to pending in the same statementretryable errorbackoff elapsedretries exhausteda change: widen the scopea change: set dirty
Figure 5. The life of an intent: one row per key, a scope of date ranges and never a value; a change widens the scope or sets the dirty flag; two conditional statements claim and finish it, so two senders cannot both own it and a change that lands mid-send is sent next.

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:

FunctionWhat it answers
capabilitiespush 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:

AirbnbBooking.comExpedia group
Reservations reach us bywebhooks; the channel's own status page has recorded delayed, missed and duplicated deliveriespull: a queue polled every twenty seconds, each reservation acknowledged within thirty minutes or the property is e-mailedpush with retries up to about two and a half hours, then e-mail; payload signed with a yearly rotating secret
Idempotency on writesnot public; documentation is behind a partner loginnone documented; writes are absolute values; unacknowledged reservations are redeliverednone documented; a retried notification carries a retry flag
Rate limitnot public; the terms say limits are set at the channel's discretionper 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 figureno number; on a 429 the partner backs off exponentially; about 180 updates per product per day expected
Bulk shapean asynchronous calendar endpoint; failures arrive later as webhooks, not as HTTP errorsone hotel and one month per rates or availability requestup to five thousand updates per message
Pending requestthe host has 24 hours; the dates stay blocked after expirypartner 24 hours, guest 24 hoursVrbo: a decline after 24 hours counts as a cancellation with a fee
iCal, where usedimports every three hoursevery two hours, and not available to a property that has a connectivity partnerVrbo 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:

FactUsed byWhy it cannot live in a cell
directory: client to cellthe edge, the analytics readers, the support toolit decides which cell a request goes to
index: external listing id to clientthe edgea webhook names Airbnb's id, not ours
channels: capabilities, limits per endpoint group, hosts, tierevery cellone copy, changed without a deploy
channel accounts: which credentials existcells, with the secrets in a vault
global limiter per channel partner accountevery senderBooking.com's limit is one for all of Hostify
migration flag per client: legacy, shadow, livethe edge, the cells, the diff tool
unrouted eventsthe edge, supporta 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:

FigureTarget
Age of the oldest critical intent, sampled continuouslyunder 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 acknowledgementseconds on an ordinary day, under a minute at a peak, bounded by the channel's limit
Open circuit breakerszero
Rows in conflicts, listings marked unsafezero
Dead intents, expired tokenszero
Database connections, ledger write rate and CPU in use, per celleach under a third of its ceiling
Analytics lag per clientminutes

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:

StepBudgetBasis
Channel to usa webhook: milliseconds; Booking.com: up to twenty seconds, the polling cadence its documentation asks forthe channels' documentation
Edge: verify, resolve, write to the queue, acknowledgeunder 100 msassumed; 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 eventunder 200 msthe 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 replica10 to 50 msa short transaction over a local replica
Wake of the senderimmediate; the sweeper is the fallback every few secondschapter 6
Limiter slot in the critical laneimmediate while the lane has capacitythe reserved share is not consumed by prices
The channel's callhalf a second to several secondsthe 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 errorthe 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

FailureWhat happensWhat a client sees
A process crashes on a bad responseits supervisor restarts it; it rehydrates from Postgresnothing
A node of a cell dies, or is paused longer than its leasethe sibling takes the leases and starts the clients' trees from the database; the old node's writes fail on their stale tokensa 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 overthe synchronous replica takes over with no acknowledged write lost; processes reconnecta pause of the provider's published failover time, 30 to 120 seconds by the offerings researched
The synchronous replica is lostcommits halt; alarm; an operator degrades to asynchronous replication knowinglya pause until the operator acts
The edge's queue is unavailablethe 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 onnothing 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 heartbeatreservations arrive late, by e-mail if the pause is long
A channel is down or slowits breaker opens, its lane pauses, the others continue; retries with backoff; alarmthat 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 answeran immediate pull of its reservations; conflict if one overlaps; the listing unsafe; alarm with the reasonthe listing is closed everywhere until a person clears it
A channel delivers the same reservation twice, or out of orderthe inbound event is keyed by the channel's reservation id; the state machine reads the channel's current state and reconciles to itnothing
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 rowreservations appear late; the gap is measured
The control plane is unreachablecells use cached directory and static lane shares; the edge uses its cached indexprices may run slower
The analytics store is downthe change feed accumulates in the cells; lag alarmreports go stale; nothing else
The hosting platform is downnothing is sent or received; channels keep selling what we last sentstale 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 themthe second allocation is refused by the constraint; the reservation goes to conflict; the listing is unsafe; alarma 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.

#QuestionWhat it decides
1How 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
2On 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
3The 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 feedWhich adapter the contract is checked against first, and the certification calendar
4Where 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 visibilityWhat shadow mode diffs against, and what it is designed to catch
5The largest client: listings, units, channels, reservations a day, and the shape of its price burstsThe ceiling the spike must measure, and whether that client is a cell of its own
6For 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 AirbnbThe adapter's retry rules and whether the inbound read-back of chapter 5 is needed on every event or only on disorder
7Is 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
8Does 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)

FactSource
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 accesshttps://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 numbershttps://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 minuteshttps://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 availabilityhttps://developers.booking.com/connectivity/docs/ota-hotelavailnotif (read 2026-10-12)
Booking.com's own causes of overbooking, including automatic re-release of cancelled nightshttps://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 ratehttps://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 partnerhttps://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 discretionhttps://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 hourshttps://www.airbnb.com/help/article/99 (read 2026-10-12)
Airbnb request to book: the host has 24 hourshttps://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 reservationshttps://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 endpointhttps://developers.booking.com/connectivity/docs/reservations-api/reservations-overview (read 2026-10-12)
Expedia partner programme: metric groups without published thresholdshttps://developers.expediagroup.com/supply/lodging/cpp/overview (read 2026-10-12)
Vrbo iCal every 30 minutes; a decline after 24 hours counts as a cancellationhttps://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 2026https://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)

FactSource
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)

FactSource
Exclusion constraints over ranges; GiST; btree_gisthttps://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 recheckhttps://www.postgresql.org/docs/current/index-unique-checks.html (read 2026-10-12)
Overlap rejection at READ COMMITTED without SERIALIZABLE; errors 23P01 and 40P01https://dev.to/franckpachot/postgresql-exclude-constraints-for-better-concurrency-than-serializable-pob (2025-05-15)
Postgres 18 WITHOUT OVERLAPShttps://www.postgresql.org/docs/18/release-18.html (2025-09-25)
SQLite has only PRIMARY KEY, UNIQUE, CHECK and FOREIGN KEY constraintshttps://www.sqlite.org/lang_createtable.html (read 2026-10-12)
SKIP LOCKED is for queue-like tableshttps://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 vacuumhttps://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 relayshttps://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 keyshttps://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 secondshttps://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 readerhttps://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Concepts.AuroraHighAvailability.html (read 2026-10-12)
Cloud SQL: synchronous regional disk, about 60 secondshttps://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 replicahttps://planetscale.com/docs/postgres/scaling/replicas (read 2026-10-12)
Neon: commit on a Paxos quorum of safekeepers; recovery times (claims); now Databrickshttps://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 statedhttps://docs.crunchybridge.com/concepts/high-availability (read 2026-10-12)
Supabase: single node by default, failover on the Enterprise plan, PITR two-minute RPOhttps://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)

FactSource
AWS cell-based architecture: no shared state, thinnest router, control plane may fail, size for the largest customer, full mappinghttps://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 shardhttps://learn.microsoft.com/en-us/azure/architecture/patterns/sharding (2026-04-02)
Database-per-tenant costs: a catalog, schema management at scalehttps://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 limitshttps://docs.citusdata.com/en/stable/use_cases/multi_tenant.html (read 2026-10-12)

11.5 Erlang/OTP and Elixir (chapters 5, 9, 11)

FactSource
A process is 338 words when spawned, heap of 233 wordshttps://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 limitshttps://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 reductionshttps://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-emptedhttps://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_changehttps://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 recommendedhttps://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 ownerhttps://www.erlang.org/doc/apps/stdlib/ets.html (read 2026-10-12)
DETS files cannot exceed 2 GBhttps://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.2https://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 healshttps://hexdocs.pm/horde/eventual_consistency.html (read 2026-10-12)
Ecto turns an exclusion violation into a changeset errorhttps://hexdocs.pm/ecto/Ecto.Changeset.html#exclusion_constraint/3 (read 2026-10-12)
Oban: transactional enqueue, unique jobshttps://hexdocs.pm/oban/Oban.html (read 2026-10-12)
Broadway back-pressure; a community Redis Streams producerhttps://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 autostophttps://fly.io/docs/litefs/ (read 2026-10-12)
Observability: telemetry, LiveDashboard, Observer, recon, OpenTelemetryhttps://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)

FactSource
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 alarmhttps://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 recommendedhttps://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 queueshttps://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 writehttps://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-05https://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)

FactSource
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 patternhttps://redis.io/docs/latest/develop/data-types/streams/ (read 2026-10-12)
Lua scripts are atomic; keys in one hash slothttps://redis.io/docs/latest/develop/programmability/eval-intro/ (read 2026-10-12)
Token bucket in Luahttps://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.

#SeverityFindingDisposition
1CriticalThe exclusion constraint guarded the listing; the overlaps that matter are per physical unitFixed: occupancy table per unit, chapters 3 and 6
2CriticalNo deduplication before allocation; a retried webhook became a false conflictFixed: routing by the channel's reservation id, chapter 5
3CriticalThe edge acknowledged before anything durable existed; three chapters disagreedFixed: durable queue, acknowledgement after the write, chapters 4, 8, 9
4HighOut-of-order inbound events were answered with an outbound argumentFixed: read-back from the channel, held cancellations, chapter 5
5HighA unique row does not stop two nodes from sending; no fencingFixed: lease with a fencing token on every statement, chapters 5, 6
6HighThe dirty flag had a lost-update window; the version column was unexplainedFixed: two conditional statements; column removed, chapter 6
7High"No data in the intent" contradicted "one bulk call for the changed dates"Fixed: key and scope, never a value, chapters 3, 6
8High"Within seconds" had no budget; the shared partner limit is the real boundFixed: budget table and drain calculation, chapter 9
9HighThe global limiter broke "a hot listing slows nothing else" and cross-cell fairnessFixed: limiter lanes per cell, chapters 3, 4, 8
10HighA channel refusing a close was not handledFixed: pull, conflict, unsafe, alarm, chapters 3, 5, 9
11High"Never an overbooking" during a platform outage contradicted the summaryFixed: the window is the outage, chapters 1, 4, 9
12HighThe reconciler's direction was undefinedFixed: import reservations, push availability, chapter 7
13HighShadow mode did not say where its inputs came from; chapters 10 and 91 contradicted each other on reading the legacy databaseFixed: three inputs and their mechanisms; shadow reads the legacy database read-only, chapter 10
14HighCutover ignored the channel-side binding, the dual-send window and the limit of rollbackFixed: binding decision, fan-out, window, horizon with back-fill, chapter 10; ask 2
15HighThe API Hostify's own application uses was absentFixed: a component of the cell, chapter 8
16HighPersonal data contradicted "pseudonymised" and "permanent archive"; no retention or erasureFixed: tokens at write, retention table, erasure, archive named, chapters 6, 8, 9
17High"Not complex" was overstated; the parts were nine kinds, not threeFixed: inventory table and a day-one deployment of one cell, chapter 4
18MediumRange semantics unstated: half-open, partial, alteration, btree_gistFixed: chapter 6
19MediumManual blocks and owner stays were invisible to the constraint; availability had two homesFixed: blocks and holds in the occupancy table; availability derived, chapters 3, 6
20MediumTime zones, DST and the definition of a night were absentFixed: chapter 6
21MediumWhat "unsafe" does was undefinedFixed: closes every night everywhere until cleared, chapters 3, 5, glossary
22MediumRequests to book and holds had no policyFixed: hold kind with expiry, chapters 5, 6; ask 8
23Medium"A transition nobody thought of is a compile error" is not true in ElixirFixed: a crash caught by the supervisor and a state-by-event test, chapter 5
24MediumThe fairness argument was unfair to other runtimes and aimed at the wrong resourceFixed: scheduler as convenience, per-client caps as the mechanism, chapter 5
25MediumFailover numbers and the synchronous replica's liveness cost were not statedFixed: providers' figures and the halt-on-replica-loss policy, chapters 6, 9
26MediumNumbers rested on an appendix and a review that did not existFixed: appendix of sources; review chapter; summary rewritten
27MediumWho writes inbound events, and whether the engine is decided, were stated three waysFixed: the queue drainer writes; recommended subject to the spike's pass figures, chapters 1, 13
28MediumThe alternatives chapter was unfair in four cells; hiring and ecosystem costs omitted; provider claim overstatedFixed in the four cells and the ecosystem cost; portability qualified, chapter 11. Declined for hiring: no staffing content by the owner's instruction
29MediumThe pricing model and currencies were missingFixed: chapter 6
30MediumRestrictions had no safety classification; ordering between kinds was unexaminedFixed: restrictions in the critical lane; declared order of kinds, chapters 3, 7
31MediumEight internal inconsistenciesFixed, each in its chapter
32MediumThe 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
33MediumMoving a client between cells had no protocolFixed: numbered procedure, chapter 9
34MediumBackups were a table cell, not a procedureFixed: chapter 9
35MediumEdge-to-cell transport, webhook secrets and EU residency were not reconciledFixed: regional option, edge secret store, queue pull, support access, chapters 8, 9
36MediumThe invariants themselves were untestedFixed: a test per invariant, chapter 13
37MediumCost, team and timeline were absentDeclined: outside this document by the owner's instruction; recorded in chapter 12
38LowEight wording itemsFixed, each in its chapter

12.1 The second round

#SeverityFindingDisposition
N1HighBooking.com's reservation queue is one per partner; a per-client flag cannot partition it; shadow mode saw a sampleFixed: one partner poller for the migration, chapter 10; parts and failure tables
N2HighA refused open or a missing price marked the listing unsafe everywhereFixed: the taxonomy split by direction, chapters 3, 5
N3HighThe drain figure rested on an unquoted reading of the rate-limit tableFixed: the table quoted with endpoint names, chapters 7, 9, appendix
N4HighThe edge queue was a single Cloudflare dependency, denied in chapter 11; its residency unsourcedFixed: second receiver, risk rows, chapter 11 rewritten, residency stated as unconfirmed, chapters 1, 4, 8, 9, 11, 13
N5MediumThe parts count was not applied to the conventional designFixed: chapter 11 row with chapter 4's rows
N6Medium"Queue to cell: local network" was false for an edge queueFixed: chapter 9
N7MediumThe edge row's basis was not in the appendixFixed: appendix rows for the Worker and queue figures
N8MediumAirbnb keeps a request's dates blocked; chapter 6 said a hold always releasesFixed: a per-channel capability, chapters 3, 5, 6, 7
N9MediumEight new statements without a sourceFixed: sourced in the appendix or removed (Airbnb Preferred+)
N10MediumAirbnb's asynchronous endpoint had no awaiting stateFixed: chapters 5, 6, 9
N11MediumBooking.com's polling cadence was outside the budgetFixed: a first row, chapter 9; the summary qualified
N12MediumThe glossary contradicted the chaptersFixed: glossary revised
N13LowThe client move had a cell calling a cellFixed: written to the new cell's queue, chapter 9
N14LowSmall inaccuracies in the review chapter and the brief; sessions pinned to the old cellFixed: chapters 2, 3, 6, 8, 12
N15LowThe target figure in three framingsFixed: the number in the health table, chapter 9

13 Appendix: glossary

TermMeaning in this document
ChannelAn online travel agency or another site where a property is sold: Airbnb, Booking.com, Expedia, Vrbo, an iCal feed
Channel managerThe system that keeps a property's availability, prices and reservations identical on every channel
Client, tenantA Hostify customer: a property manager with one or many properties
ListingOne sellable unit as a channel sees it; a property may have several listings, and one physical unit may be sold under several linked listings
PropertyA group of units whose nights must be allocated together, with one time zone
UnitOne 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
OccupancyA row that holds a unit for a range of nights: a reservation, a block (maintenance, owner stay) or a hold (a pending request)
HoldAn 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
UnsafeThe 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 tokenThe 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
ScopeThe merged set of date ranges an intent covers; the intent carries a scope and never a value
NightThe unit of the calendar: one listing, one date, with its availability, price and restrictions
OverbookingTwo reservations holding the same night of the same unit
CellA bounded group of clients with its own compute and database; nothing in a cell depends on another cell
Control planeThe small shared set of facts every cell needs: which client is in which cell, the channels' configuration, the global rate limiters
EdgeCloudflare 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
QueueThe 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
KindThe sort of data an intent carries: availability, restrictions, rates, content
IntentA record that a key must be synchronised to a channel; it names the key and never carries the data
KeyTenant, listing, channel and kind of data; the unit of ordering
LaneThe priority class of an intent: critical (availability and restrictions), bulk (rates), content
LedgerThe four tables that record everything: inbound events, intents, attempts, manual interventions
OutboxThe pattern of writing the work to propagate a change in the same transaction as the change
Process, actorA 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 treeA process whose job is to restart its children when they fail; the tree of supervisors is the recovery model
State machineA component with named states and named transitions; the reservation is one
Exclusion constraintA Postgres rule that refuses two rows whose ranges overlap on the same key; here, two occupancies of one unit on the same nights
Synchronous replicaA second database that must confirm every write before the first acknowledges it; a failover to it loses nothing
Circuit breakerA guard that pauses calls to a channel whose errors climb and tests it again after a cooling period
Rate limitThe number of calls a channel accepts per unit of time, often per partner account
IdempotentAn operation that can be repeated without changing the result; the property that makes at-least-once delivery safe
ReconciliationA periodic full comparison with the channel, in two directions: reservations are imported from the channel, availability and rates are pushed from ours
Shadow modeRunning 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 feedThe sequence of changes a cell publishes for the analytics store
Analytics storeThe derived, shared database for reports and cross-client questions; never on the path of a reservation
SpikeA short, measured build of the core without channel adapters, to answer the open numbers