BlogEngineering

ENGINEERING

Fifty dials per number per day, and why the limit has to be atomic

Two dial paths read the same counter, both see 49, both decide there is room. A cap enforced that way is not a cap. It is an average.

SAGARISEngineering8 min
Fifty dials per number per day, and why the limit has to be atomic

Two dial paths can resolve the same caller-ID number in the same instant. The human power dialer picks it for a rep's next call. The AI outbound path picks it for a different lead. Both read today's count for that number and both get 49. Both compare 49 against a ceiling of 50, both conclude there is room for one more, and both write 50. The counter now says 50. Fifty-one calls went out.

The migration that fixes this says it plainly in its own header, and the sentence is worth reading twice: two legs both read 49, both decide ok, both write 50, so a 51st dial slips through. That is a one-call overrun on one number on one day. It sounds like rounding error. It is not.

What the number is protecting

Carrier analytics flag a caller-ID number as "Spam Likely" when its daily dial volume bursts above a threshold. Once a number is flagged, every call from it is degraded for every rep who uses it, and the label follows the number rather than the campaign that earned it. So the ceiling is not an internal politeness rule. It is a defence of an asset that takes weeks to build and one bad day to lose.

The product guarantee is at most 50 dials per caller-ID number per calendar day: a default ceiling of 50, defined once as a single constant and tunable per deployment through one environment variable. The word doing the work in that sentence is not fifty. It is "at most".

The race condition, without jargon

Engineers call this a TOCTOU bug, for time of check to time of use. The plain version is the last seat on a flight.

Two agents at two desks are both asked to sell the last seat. Both look at the same screen, both see one seat remaining, both say yes, and both take a payment. Nothing either agent did was wrong in isolation. Each of them checked. The problem is that the check and the sale were separate acts with a gap between them, and in that gap the world changed underneath both of them.

Software has the same gap, except it is measured in milliseconds and it happens thousands of times a day. A dialer that reads a counter, evaluates "is this below the cap?" in application code, and then writes an incremented value back has three separate steps with two gaps. Under any real concurrency, and a power dialer with parallel legs plus a second automated dial path is real concurrency, some of those gaps get used.

The consequence is specific and it is the reason this article exists. A limit enforced across concurrent workers without atomicity is not a limit. It is an average. Most days it holds. On the busy days, which are exactly the days that matter, it leaks. And the carrier scoring your number does not grade on averages. It grades on what actually arrived.

The fix is one statement

The check and the increment are folded into a single SQL statement so there is no gap to exploit. A counter row exists for each combination of workspace, caller-ID number and calendar day. Claiming a slot is one INSERT that carries an ON CONFLICT DO UPDATE clause: insert the row with a count of one if today has no row yet, and otherwise increment the existing count by one, but with a WHERE condition on that update restricting it to rows whose stored count is still below the cap. The statement returns the resulting count.

That WHERE condition is the entire guarantee. The increment happens only while the stored count is still below the cap, and the condition is evaluated inside the row lock Postgres already takes to perform the upsert. Two claimants arriving at count 49 are serialised by the database. One of them matches the condition, increments to 50, and gets the new count back. The other finds the row already at the cap, matches no row for the update, and gets nothing back at all.

That "nothing back" is the signal. The claim returns NULL, and NULL means no slot, and no slot means the leg is not placed. There is no moment at which two callers can both believe they hold the same slot, because there was never a moment when the check and the claim were separate.

The migration notes that this mirrors the pattern used for monetary correctness elsewhere in the codebase, for exactly the same reason. When two things must not both be true, the reliable way to enforce it is to make the database decide, once, in one statement.

The failure that a naive fix introduces

Here is the part that is easy to get wrong, and the codebase got it wrong first and then wrote down why.

The claim can come back three ways, not two. It can succeed. The number can genuinely be at its ceiling. Or the cap can fail to be evaluated at all, because the database function is missing during a deploy that ran ahead of its migration, or a connection blipped.

The first version collapsed the last two into one "no slot" answer. The result, recorded in the comment on the outcome type itself, is that a missing database routine blocked 100 percent of dials while reporting that every number had reached its cap. The dialer went silent and the reason it gave was a lie.

So the outcomes are kept apart as three distinct values in the type, and they get opposite treatment:

- Slot claimed: the slot is reserved, the dial goes out on that number. - At cap: the number is genuinely at its ceiling. Fail closed. Do not place the call. - Could not be evaluated: the cap check itself failed. Fail open. Place the call and log loudly.

Failing open on an error looks like a weakening of the control, and it is worth being explicit about why it is not. The dial cap is a carrier-reputation heuristic, not a safety invariant. Consent, suppression and internal do-not-call gates are safety invariants, and those fail closed without exception. A transient database blip should never take a customer's outbound calling off the air, and a control that does will be ripped out within a month by the person whose team could not dial. The discipline is knowing which of your controls is which, and writing the difference down in the type rather than in a wiki.

A softer layer sits in front of the hard claim: a pure function that drops any number already known to be at its cap out of the selection pool before a number is chosen at all. It exists to make the hard claim rare, not to be the guarantee. The guarantee is the SQL.

A new number is not a mature number

A steady ceiling of 50 is still wrong on day one. A brand-new caller-ID number with no calling history that immediately places fifty cold dials is behaving exactly like the thing carrier models are built to catch.

So the effective cap ramps with the number's age: 15 dials a day in its first week, 25 in the second, 35 in the third, and the steady cap from week four onward. The result is always the lower of the ramp step and the configured steady cap, so a customer who sets a stricter ceiling wins at every step and the ramp can never raise a number above it.

The honest part of that design is in the comment above it. Carriers and their analytics vendors publish no official per-number daily-dial thresholds. The models are proprietary and unpublished. There is no authority to cite, so the steps are deliberately conservative common practice rather than a derived number, and the code says so instead of implying a precision it does not have. If a vendor quotes you an exact safe daily volume per number, ask them which carrier published it.

Who is allowed to write the counter

A cap that the capped party can edit is not a cap. The counter table is member-read and service-write. Workspace members can select their own counters for reconciliation. There is deliberately no member insert, update or delete policy. The only writer on the hot path is the claim function itself, running as a definer-scoped routine, workspace-scoped by argument, touching exactly one row.

The reason is written into the migration: a member-writable counter is a cap bypass in both directions. Someone could zero it and dial without limit, or set it to the ceiling and take their own numbers off the air.

The day bucket is the UTC calendar date, so every parallel leg agrees on the same day without a timezone argument.

What this does and does not buy you

None of this is possible when you rent dialing from a shared pool. Deciding how many calls a specific number may place today requires owning the number, the pool it sits in, and the path the call takes out. SAGARIS runs an Asterisk PBX with a Telnyx trunk, dedicated per-client DID pools, per-DID registration with FreeCallerRegistry, and local presence selection.

The bounds, stated plainly. The parallel-dialer phases beyond the core path are not generally available yet. External do-not-call registry scrubbing is not provisioned, so internal do-not-call, suppression and consent gates are the controls that are actually running. And a migration is a file until somebody applies it to a database; this one carries a handover header saying exactly that, and whether it has been applied to the live instance is a question you answer by querying the database, not by reading the repository.

What the cap does buy is a ceiling that holds when two dial paths collide, which is the only condition under which a ceiling was ever needed.

SAGARIS

Written by the SAGARIS team.

See the engine run on your pipeline.

Thirty minutes, your own data, no setup.

Book a demo

Get the next one in your inbox.

SAGARIS opens fully in October 2026. Join the waitlist and we will be in touch before launch.

We use these details to contact you about SAGARIS. See our privacy policy.

Book a demo