Deterministic Scheduling with Identity Modulo
Explains a deterministic method for distributing daily scheduler load across 24 hours through an identity-based modulo transformation. Balance, collisions, reproducibility, and time-zone behavior are addressed.
A periodic task can assign every record deterministically to a specific hour of the day instead of scanning all records on every execution:
bucket(id) = id mod 24
The task processes only records assigned to the current hour:
process(id, hour) ⇔ id mod 24 = hour
This method divides the daily workload into 24 time slots instead of evaluating all roughly 28,000 records on every scheduler trigger. It requires no central queue, distributed lock, persistent cursor, or dynamic scheduler. The hour assigned to a record can be recalculated solely from its immutable identity and the hour value.
The simplicity of the approach can be misleading. The modulo operation only partitions numbers into classes. Whether the resulting load is actually balanced depends on the identity distribution and the processing cost of each record. The number of scheduler executions within an hour, retry behavior after a failure, and whether processing the same record more than once on the same day is safe must also be defined explicitly.
When designed correctly, this model provides stable load distribution for small and medium-sized periodic jobs with very low operational cost.
Creating time partitions with modulo
Assume that record identifiers are positive integers. The day is divided into 24 hourly partitions:
H = {0, 1, 2, ..., 23}
The hour partition for each record is defined as:
hᵢ = idᵢ mod 24
hᵢ is always between 0 and 23.
At hour 15, only records satisfying:
id mod 24 = 15
are selected. The same record falls into hour 15 again on the following day. The assignment does not change and does not need to be stored in another state table.
The basic condition in C# is this simple:
if (userId % 24 == DateTime.Now.Hour) { Process(userId); }
The equivalent condition for database-side prefiltering is:
WHERE USERID % 24 = @Hour
This structure is a form of deterministic temporal partitioning. In conventional sharding, records are distributed among databases or nodes. Here, the same principle is applied to time slots rather than physical nodes:
spatial partitioning: record → server temporal partitioning: record → hour
The aim is not to increase parallel throughput but to spread periodic work along the time axis.
If the total record count is N and the partition count is B = 24, the ideal partition size is:
μ = N / B
For N = 28,742:
μ = 28,742 / 24 ≈ 1,197.58
Each hour is therefore expected to process approximately 1,198 records.
The actual distribution does not have to be perfectly equal. The record count may not be divisible by 24, and the identity set can contain deleted, skipped, or patterned values. What matters is that the differences do not become large enough to disrupt operational capacity.
Evaluating the actual distribution
The hourly partition counts obtained for 28,742 records are:
0 → 1230 1 → 1219 2 → 1230 3 → 1261 4 → 1186 5 → 1190 6 → 1180 7 → 1165 8 → 1148 9 → 1196 10 → 1205 11 → 1177 12 → 1179 13 → 1229 14 → 1207 15 → 1198 16 → 1241 17 → 1148 18 → 1182 19 → 1204 20 → 1177 21 → 1213 22 → 1140 23 → 1237
The largest partition is:
max = 1261
The smallest partition is:
min = 1140
The absolute difference is:
Δ = 1261 - 1140 = 121
records.
Relative to the ideal mean, the deviation of the largest partition is:
(1261 - 1197.58) / 1197.58 ≈ 5.29%
The deviation of the smallest partition is:
(1140 - 1197.58) / 1197.58 ≈ -4.81%
This distribution is balanced under the assumption that per-record processing costs are similar. The difference between the busiest and lightest hours is approximately 10.6 percent:
1261 / 1140 ≈ 1.106
In other words, the busiest partition contains roughly 10.6 percent more records than the lightest partition. For many periodic data-processing tasks, this difference is much smaller than the operational cost of building dynamic queue management.
Record count alone is not sufficient, however. The actual load is:
Lₕ = Σ cost(i) i mod 24 = h
Here, cost(i) represents the processing duration, query count, network traffic, or generated data volume for the corresponding record. If all users have similar cost:
cost(i) ≈ c
then partition load is approximately proportional to record count:
Lₕ ≈ c × nₕ
If some records cost hundreds of times more than others, balanced record counts do not imply balanced workloads. A large organizational unit can process thousands of child records while a small unit completes after only a few queries. In such a case, identity modulo alone can be insufficient.
The first validation should therefore use record count, while the second should use actual runtime:
bucketCount[h] bucketDuration[h] bucketRows[h] bucketBytes[h]
The critical metrics are P95 and maximum execution time per partition rather than only the average. If one hourly partition runs longer than the scheduler period, the schedule is unsafe even when the numeric distribution appears balanced.
Why identity-based distribution is often balanced
Modulo distribution is naturally balanced for sequentially generated identifiers. If identifiers progress continuously as:
1, 2, 3, ..., N
every 24 consecutive records place exactly one record into each of the 24 partitions.
In that case, the difference between partition counts is at most one:
|max(nₐ - nᵦ)| ≤ 1
Real databases contain deleted rows, unused identifiers, imported data from different sources, or different starting values, so this perfect balance can deteriorate. Even so, the distribution usually remains balanced unless deletions systematically target particular modulo classes.
For example, in a data set where only even identifiers are active:
id mod 24
fills only even-numbered partitions. Odd hours remain empty. Similarly, if identifiers are generated in multiples of 24, every record falls into partition zero.
The reliability of modulo partitioning therefore depends on the following assumption:
the low-order bits or remainder classes of identifiers must not be systematically correlated with workload
If the identifier-generation pattern violates this condition, a stable hash can be used instead of the raw identity:
bucket(id) = floorMod(hash(id), 24)
A hash can distribute sequential or patterned identifiers more uniformly across remainder classes. It adds complexity, however, and the hash function must remain unchanged across all versions. Changing the hash moves the processing hours of all records at once.
If the measured distribution already has deviations of only about five percent, a hash is not necessary. A simple modulo distribution that has been measured and found adequate is usually more valuable than a theoretically smoother but more complex function.
Effect of running twice within an hour
Consider a scheduler that runs at the beginning and midpoint of every hour. Because of startup delay, actual executions can occur at approximately:
15:01 15:31
In both runs:
DateTime.Now.Hour = 15
so the same partition is selected.
This behavior creates a natural second opportunity for the same hourly partition. Records that experienced a temporary database, network, or resource failure during the first run can be attempted again during the second.
The benefit is safe only when the operation is idempotent. Processing the same record twice on the same day can otherwise cause:
- Insertion of the same data twice
- Delivery of the same notification twice
- Overwriting the same file with different content
- Incrementing counters twice
A safe processing model should provide at least one of the following properties:
idempotent upsert to the same target key skip when an existing output is present a unique constraint on processing date + record identity a deterministic file path persistent success state
For example, if a daily output is produced for every user, the target path can be deterministic:
/yyyy/MM/dd/{userId}.json
If the first execution publishes the file atomically, the second sees that it already exists and skips it. If the first execution stops midway, the target file does not exist and generation can run again.
For database writes, a unique key on:
(user_id, process_date)
can prevent the second attempt from creating a duplicate record. The implementation should use a semantically appropriate MERGE, controlled UPDATE, or explicit existence check rather than simply catching and ignoring an INSERT failure.
The two triggers should be interpreted as:
first execution → primary attempt second execution → compensating attempt for the same partition
The second execution does not necessarily need to process every successful record again. Records with persisted success state can be skipped so that only missing or failed work is retried.
Race conditions at the hour boundary
Reading the current hour repeatedly during processing can cause different records to be evaluated against different partitions when the clock crosses an hour boundary:
foreach (final User user in users) { if (user.Id % 24 == DateTime.Now.Hour) { Process(user); } }
If the task starts at 14:59:59 and the hour becomes 15:00 during the loop, the first part of the list can be evaluated for partition 14 and the rest for partition 15. This violates the deterministic partition contract of a single execution.
The hour should be captured once at the beginning:
final int hour = DateTime.Now.Hour;
foreach (final User user in users) { if (user.Id % 24 == hour) { Process(user); } }
C# does not have final for a local variable, so actual code uses int hour. The principle is that the value is not read again during the loop.
A safer design uses the scheduled execution time. If the task was scheduled for partition 15, the targeted partition should remain explicit even if actual startup is delayed until after 16:00.
Reading only the wall clock can select the wrong partition after a long delay:
scheduled partition: 15 actual start: 16:02 DateTime.Now.Hour: 16
This risk may be low for short, regular local scheduler jobs. If the system can be delayed for more than one hour under load, the trigger should pass the partition as a parameter:
ProcessBucket(15)
The business logic is then decoupled from the operating-system clock.
The time zone should also be defined explicitly. In regions that use daylight-saving transitions, local time can repeat one hour or skip it entirely. Turkey's fixed UTC+03:00 offset eliminates this particular fluctuation. Even so, defining an explicit TimeZoneInfo or application-level time policy is safer.
Daily guarantees and missed hours
Modulo scheduling targets every record once while the task runs regularly throughout the day. If the application is down during one hour, however, records in that partition may not be processed that day:
application down: 03:00–04:00 missed partition: id mod 24 = 3
The records are selected again at 03:00 the next day, but a one-day delay occurs when daily completion is required.
Modulo assignment alone is therefore not a delivery guarantee. It only defines the planned execution time. Persistent state is required to complete missed work:
due(i, date) = bucket(i) = hour AND completed(i, date) = false
A more resilient model can process the current partition first and then scan a limited number of outstanding items from previous closed days:
first the current partition then incomplete work from the most recent closed day
Alternatively, the last successful execution time can be stored for every record:
lastSuccess(userId)
The selection condition can then be extended as:
id mod 24 = currentHour OR lastSuccess < requiredDate
This preserves the normal load of the current hour while allowing missed records to be recovered over time.
Loading all delayed records in one execution can destroy the balance provided by modulo partitioning. Backlog processing should have explicit limits:
currentBucketLimit backfillLimit totalExecutionDeadline
For example, a task can process approximately 1,200 records from the current partition and then at most 100 delayed records. Historical debt is reduced in a controlled manner without exceeding the scheduler period.
The partition count does not have to be 24
Twenty-four partitions are natural for hourly scheduling, but the partition count can be derived directly from the scheduler period.
If the task processes a different partition every half hour:
B = 48
and the partition number is:
slot = hour × 2 + half
where:
half = 0, minute < 30 half = 1, minute ≥ 30
Record selection becomes:
id mod 48 = slot
This model processes approximately one forty-eighth of the records in each half-hour run. The total daily workload remains unchanged, while the load of one execution is halved.
Running the same hourly partition twice, in contrast, provides failure compensation. The two approaches serve different purposes:
24 partitions + two runs per hour: a second attempt for the same work
48 partitions + one run per half hour: divide the workload into smaller pieces
The correct choice depends on system requirements. If temporary failures are likely to clear on the second attempt, 24 partitions are advantageous. If one execution approaches the scheduler interval, 48 partitions may be safer.
Changing the partition count changes the time assignment of all records:
id mod 24 ≠ id mod 48
The partition count is therefore part of the scheduling schema rather than an ordinary configuration value. A migration policy is required so that the old and new arrangements do not create duplicates or omissions on the transition day.
Deterministic partitioning instead of a dynamic queue
A central work queue provides more flexible load balancing. An idle worker can take the next item, expensive records spread naturally over time, and priorities can be managed. The queue also requires additional components:
- A work record
- Delivery state
- Visibility timeout
- Retry counter
- Dead-letter management
- Coordination among concurrent consumers
- Maintenance and observability infrastructure
For a single process with a limited number of records, this cost can be unnecessary.
Modulo partitioning does not store work-distribution state:
assignment = pureFunction(id, partitionCount)
The result can be recalculated. No central coordination is required, and every execution produces the same result for the same data set.
In exchange, dynamic balancing is limited. Long-running records do not move automatically to another partition. Priority work cannot move ahead without an additional rule. If a partition is missed, delivery guarantees must be implemented separately.
Modulo-based scheduling is especially suitable under the following conditions:
- Record identifiers are stable.
- Workload is approximately similar across records.
- Total volume can be processed daily by one process.
- Complex queue infrastructure is undesirable.
- Reprocessing the same record is safe.
- Missed hourly partitions can be compensated.
A persistent queue can be more appropriate when per-record costs vary greatly, deadlines are strict, or horizontal worker scaling is mandatory.
The value of modulo scheduling does not come from being the most advanced load-balancing algorithm. It comes from eliminating the need for more complex infrastructure when the problem is sufficiently regular.
The system contract behind the simple formula
Choosing an hour by identity modulo has constant computational cost:
O(1)
If all records are scanned in the application, total selection cost is:
O(N)
When the filter is pushed to the database and an appropriate access plan is available, only the target partition reaches the application. The expression USERID % 24 can, however, limit direct use of a normal index. For large tables, a virtual column or function-based index can be considered:
BUCKET AS (MOD(USERID, 24))
An index can then be created on this column. At roughly 28,000 records, a full scan may be inexpensive in many systems. The actual execution plan should be measured before adding schema complexity.
Correctness comes not from the formula itself but from the surrounding contracts:
Is the identity stable? Is the partition count fixed? Is the hour read once? Is processing idempotent? What does the second trigger mean? How are missed partitions completed? Does record count correlate with actual cost?
Using only a % 24 condition without answering these questions can distribute load without creating a reliable scheduler design.
When the conditions are defined explicitly, the method gains a strong property: the time at which a record is processed is derived from a pure, verifiable function rather than external state.
time partition = identity mod partition count
This deterministic relationship simplifies restarts, test generation, load estimation, and failure analysis. Not every problem in a complex system requires a dynamic coordination layer. Sometimes the most reliable distribution mechanism is to divide an immutable identity by a fixed number and implement the contract represented by the remainder completely.