This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Packing Algorithms in C# Part 3 Streaming Availability Holds and a Moving Window
Tuesday, 10 November 2026
At nine o'clock, the salon has an appointment available next Tuesday. At nine-oh-one, someone holds it while deciding. At nine-oh-six, that hold expires. Nothing about next Tuesday's opening hours changed, but the answer to “can I book this?” changed twice in five minutes.
Rebuilding future availability overnight won't keep up with that. We need to update what changed, expire temporary allocations, and keep a useful stretch of the future ready to query as time moves forward.
In Part 2, we used the same calendar logic for employee rosters and customer appointments, then ranked feasible offers. Here we give that calendar a stream of changes, holds and a bounded look-ahead window. We are deliberately still working on the logic, with a console example rather than a UX.
This assumes ordinary .NET knowledge: collections, locks, async methods and dependency injection. We'll explain the scheduling and streaming ideas through the salon. All code is an independent teaching example.
The complete .NET 10 sample has no extra package dependencies:
dotnet run --project samples/StreamingAvailability/StreamingAvailability.csproj
dotnet run --project samples/StreamingAvailability/StreamingAvailability.csproj -- --check
Keep the useful cache and change how it is maintained
One approach is a scheduled ETL job: extract bookings and working hours, transform them into available slots, and load the result into a fast read store. Readers get a daily availability map.
Precomputing a useful answer can be sensible. The weakness is treating the next batch run as the way that answer becomes current. A live system also has holds, cancellations, new bookings, changed shifts and station outages.
Instead, keep a materialized view: data calculated in advance for reading, updated as its inputs change. Microsoft's materialized-view guidance describes that separation. A full rebuild remains useful for startup, recovery and checking correctness; it doesn't have to be the normal response to one appointment changing.
We will distinguish three things:
| Data | Purpose | How it changes |
|---|---|---|
| Booking facts | Holds, confirmed reservations and published resource capacity | Accepted commands |
| Resource availability view | Capacity and usage over a bounded future window | Small updates from committed changes |
| Appointment recommendations | Feasible service, technician, station and start combinations for a client | Query current availability, then rank |
Keeping those separate avoids precomputing every client × service × technician × station × future time combination. Most of that combination space will never be requested.
flowchart TD
A[Published shifts stations and booking commands] --> B[Authoritative booking state]
B --> C[Ordered committed changes]
C --> D[Rolling resource availability view]
D --> E[Find feasible appointments]
E --> F[Rank for the client]
F --> G[Customer chooses an offer]
G --> B
H[Clock reaches a hold deadline] --> B
A small application may serve reads from its authoritative index directly. The sample separates the read view so we can demonstrate lag and recovery. Streaming is a way to maintain state, not a requirement to add a message broker or another service.
An offer a hold and a confirmed reservation are different
An offer says an arrangement was feasible when we looked. It doesn't consume capacity.
A hold temporarily consumes the full resource bundle. A confirmed reservation continues consuming that capacity until it is cancelled or otherwise changed under the business rules.
stateDiagram-v2
[*] --> Held: Capacity check succeeds
Held --> Confirmed: Confirm before deadline
Held --> Expired: Clock reaches deadline
Held --> Cancelled: Release explicitly
Confirmed --> Cancelled: Cancel reservation
Expired --> [*]
Cancelled --> [*]
A hold has two unrelated time intervals:
Appointment occupancy: Tuesday 10:00–11:05 for the technician
Hold lifetime: Today 09:01–09:06 while the client decides
Expiring the hold releases the future appointment capacity. It doesn't wait until Tuesday, and it doesn't shorten the treatment to five minutes.
As in Part 2, the resource intervals can differ: technician time includes their reset, a station includes its cleanup, and the client occupies only the treatment interval. Hold creation acquires all those allocations together.
Use one place to decide whether capacity can be acquired
The read view can be behind the booking state. Two clients can both see the final suitable appointment as available before either client's hold reaches the view.
sequenceDiagram
participant Alice
participant View as Availability view
participant Ledger as Booking writer
participant Bob
Alice->>View: Is the appointment available?
View-->>Alice: Available at version 20
Bob->>View: Is the appointment available?
View-->>Bob: Available at version 20
Alice->>Ledger: Try to hold the resource bundle
Ledger->>Ledger: Check current capacity and allocate together
Ledger-->>Alice: Held until 09:06
Bob->>Ledger: Try to hold the same resources
Ledger-->>Bob: No capacity
Ledger-->>View: HoldCreated at version 21
The writer owns the decision. It rechecks current capacity and acquires the bundle as one operation. A cached “available” result is useful for finding options, while the hold result establishes whether this client acquired one.
Our BookingLedger uses a C# lock for one venue. Inside it, hold creation expires due holds, checks command retries, validates the proposed usage, updates the resource counts and appends the committed change. The console constructs a known resource bundle. In the salon application, the Part 2 service would construct it after applying service skills, blocked-client rules and other eligibility checks; those checks still belong at the command boundary.
For a multi-instance application, the database operation must coordinate competing writers—for example through a shared venue version or an appropriate lock inside the transaction. Simply inserting each reservation in its own transaction doesn't make two prior availability checks safe. Microsoft's transaction guidance explains atomic writes; the isolation and conflict strategy must cover this particular check-and-acquire operation.
Represent the future on a declared grid
The example uses five-minute UTC slots. Each resource cell stores two integers:
public readonly record struct Cell(int Capacity, int Used);
A slot can accept an additional demand when:
used + requestedUnits <= capacity
For a two-seat bench with one occupied seat, another one-seat appointment can fit. For a capacity-one technician, it cannot. Every slot in every resource interval must pass, including combined demands on the same resource. Zero capacity means there is no published bookable capacity for that cell; explaining a closure or an unpublished roster is a separate application concern.
A demand uses absolute slot numbers:
public sealed record Demand(string ResourceId, long Start, long End, int Units = 1)
{
public static Demand Between(string resource, DateTimeOffset start, DateTimeOffset end, int units = 1) =>
new(resource, Slots.Exact(start), Slots.Exact(end), units);
}
Slots.Exact converts an instant into its five-minute position and rejects off-grid appointment boundaries. Intervals remain half-open: [Start, End). We don't round an existing booking down and accidentally free part of it. All times must use the declared grid for this representation to be exact.
Hold deadlines remain full DateTimeOffset instants. A hold expiring at 09:06:30 does not acquire a new deadline of 09:10 merely because appointments use five-minute cells.
Resolve local opening hours and appointment times with the venue's time-zone rules before mapping them to UTC. A local day crossing a daylight-saving change isn't necessarily 288 five-minute slots. The demo uses UTC instants throughout to keep that conversion outside the capacity algorithm.
Change only the affected resource cells
A committed change describes a range update, rather than asking the reader to recalculate all future combinations:
public sealed record Change(
string ResourceId, long Start, long End,
int UsedDelta = 0, int? Capacity = null);
public sealed record CalendarEvent(
long Sequence, string Kind, Guid? ReservationId,
IReadOnlyList<Change> Changes);
UsedDelta is an addition to occupied capacity: +1 acquires one unit; -1 releases it. Capacity sets a resource's available capacity when a published schedule changes. One event can contain the whole multi-resource bundle.
| Accepted change | Occupancy update |
|---|---|
| Create hold | Add the hold's resource demands |
| Confirm hold | Keep existing usage; change reservation state |
| Expire hold | Subtract its demands once |
| Cancel held or confirmed reservation | Subtract its demands once |
| Publish changed resource capacity | Update that resource's affected range |
Confirmation doesn't add the appointment again: the hold already occupied it. An old expiry entry also cannot release a hold that has since been confirmed.
For each delta, the projection touches only its intersection with the current window:
for (var slot = Math.Max(delta.Start, _start);
slot < Math.Min(delta.End, _start + _width);
slot++)
{
// Update this resource's capacity or used count for this cell.
}
The complete implementation stages the updates, checks the resulting counts, and publishes the event's changes under one read-view lock. A reader doesn't see half of a multi-resource event applied.
Reducing a technician's capacity below existing holds or confirmed work is rejected by the sample. A roster edit needs explicit resolution of those commitments; an incremental update shouldn't make them disappear.
Expire holds without scanning every reservation
A priority queue keeps the nearest deadline at the front. When time advances, remove due entries until the next deadline lies in the future. We don't revisit every confirmed booking to find a few expired holds.
Here is the expiry handler from BookingLedger.cs:
private void ExpireDue()
{
var now = clock.GetUtcNow();
while (_expiry.TryPeek(out var id, out var deadline) && deadline <= now)
{
_expiry.Dequeue();
var hold = _reservations[id];
if (hold.State != ReservationState.Held || hold.ExpiresAt != deadline) continue;
_reservations[id] = hold with { State = ReservationState.Expired };
Adjust(hold.Demands, -1);
Append("HoldExpired", id, ToChanges(hold.Demands, -1));
}
}
The state check matters. A hold might be confirmed before its queue entry becomes due. Removing that old entry must leave the confirmed reservation's usage intact.
Confirmation also runs this expiry check inside the writer's lock before examining the hold:
public Reply Confirm(Guid commandId, string owner, Guid holdId)
{
var fingerprint = JsonSerializer.Serialize(new { Kind = "Confirm", owner, holdId });
lock (_gate)
{
ExpireDue(); // Equality with the deadline is expired, even if the timer has not fired.
if (Previous(commandId, fingerprint) is { } replay) return replay;
if (!_reservations.TryGetValue(holdId, out var hold) || hold.Owner != owner)
return Remember(commandId, fingerprint, new(false, "NotFound", Version: _journal.Count));
if (hold.State != ReservationState.Held)
return Remember(commandId, fingerprint, new(false, "HoldNotActive", holdId, hold.ExpiresAt, _journal.Count));
_reservations[holdId] = hold with { State = ReservationState.Confirmed };
Append("HoldConfirmed", holdId, Array.Empty<Change>()); // Already occupied: do not count twice.
return Remember(commandId, fingerprint, new(true, "Confirmed", holdId, Version: _journal.Count));
}
}
The policy is explicit: now >= ExpiresAt is expired. Timer delivery can be late; a confirmation exactly at the deadline still fails. An external response arriving later must resolve against the current reservation state rather than reviving an expired hold.
We inject TimeProvider so tests can move the clock without waiting five real minutes. The console's small ManualClock controls the logical clock used by these checks. It doesn't implement virtual timer scheduling.
Keep a moving window with a ring buffer
A ring buffer is a fixed-size array whose cells are reused as its logical contents advance. Each resource gets one ring covering the same future interval.
Before: [ A | B | C | D ] logical future slots A through D
After: [ E | B | C | D ] logical future slots B through E
^ reused cell
The physical array isn't shifted. Its lookup index is:
private int Index(long slot) => checked((int)(slot % _width));
Only the newly entering tail overwrites the old head. Within a contiguous window of _width slots, each absolute slot has a distinct array position. Queries outside that logical window must be rejected so they cannot read a reused cell as an unrelated date.
The sample returns Available, Unavailable or OutsideWindow, plus the view's sequence position. Outside the window means this cache cannot answer. It must not become “the salon is fully booked”. A caller can load another bounded range or use an authoritative query.
For 50 resources and a 14-day elapsed-time window:
14 × 24 × 12 = 4,032 cells per resource
50 × 4,032 × 8 bytes = 1,612,800 bytes, about 1.54 MiB of raw cell values
Add array and dictionary overhead. Booking records, command results and the event journal have their own storage requirements. The bounded window limits the read grid, not the size of all business history.
Load the entering tail from current facts
A future reservation can be made beyond today's cached window. Its event won't alter any current grid cell, but its facts must still be present when that date later enters the window.
Therefore the new tail is loaded from authoritative state. Filling it with empty cells would advertise appointments that were booked days earlier.
There's a second subtlety. Suppose the view is at version 20, and the tail snapshot is captured at version 25. Events 21–25 may also affect cells that remain in the window. We must apply those retained-cell changes before claiming the view is current at 25.
Advance handles both at one source version:
public void Advance()
{
lock (_gate)
{
var next = Math.Max(_start, Slots.Floor(_source.Clock.GetUtcNow()));
if (next == _start)
{
CatchUp();
return;
}
if (next - _start >= _width)
{
Rebuild(); // No retained cells: loading the new window is cheaper than replaying old changes.
return;
}
var oldEnd = _start + _width;
var nextEnd = next + _width;
var tailStart = Math.Max(oldEnd, next);
// Both retained-cell events and entering-tail values are captured at the same source version.
var snapshot = _source.Capture(tailStart, nextEnd, _cursor);
foreach (var change in snapshot.Pending) ApplyCore(change);
_start = next;
Hydrate(snapshot, tailStart, nextEnd);
if (_cursor != snapshot.Version) throw new InvalidOperationException("Snapshot and stream positions disagree.");
}
}
Capture takes the tail values, stream position and pending changes under the source lock. Pending events update the old window; tail values then populate the new cells. The result is one view at one version, rather than a fresh tail attached to stale retained cells.
If the clock jumps far enough that none of the old window remains, a fresh window snapshot is simpler than replaying changes into cells about to be discarded. The code takes that path too.
Time must advance even when no customer sends a message
A booking event can wake the consumer, but silence doesn't prevent holds expiring or the horizon advancing. We need both event processing and a periodic clock check.
The sample uses a bounded .NET channel as a wake-up signal. The ordered journal holds the changes themselves. Many notifications can collapse into one signal because the consumer reads every journal entry after its last processed sequence. Microsoft's Channels documentation covers this producer/consumer mechanism.
public static class AvailabilityWorker
{
// One event consumer; a periodic tick also expires holds and moves the horizon when the venue is quiet.
public static async Task RunAsync(BookingLedger ledger, RollingAvailability view, CancellationToken cancellationToken)
{
using var stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var token = stop.Token;
var events = ConsumeAsync();
var clock = AdvanceAsync();
await Task.WhenAny(events, clock);
await stop.CancelAsync();
await Task.WhenAll(events, clock);
async Task ConsumeAsync()
{
await foreach (var _ in ledger.Changes.ReadAllAsync(token)) view.CatchUp();
}
async Task AdvanceAsync()
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1), ledger.Clock);
while (await timer.WaitForNextTickAsync(token)) view.Advance();
}
}
}
The event consumer catches up after writes. The timer calls Advance, which also processes expiry and pending events when the window hasn't moved. A one-second timer is a sample cadence, not the precision of the writer's deadline checks. Use TimeProvider.System for this hosted loop; the console demonstrates the same transitions with explicit clock advances and catch-up calls.
If either loop fails, the linked cancellation stops its sibling. A host can then recover the view before restarting consumption.
Treat retries and missed events as normal cases
An idempotency key identifies one command attempt and its retries. Repeating hold creation with the same key and payload returns the original result. It doesn't acquire capacity twice or renew the deadline. Reusing that key for different content is rejected.
A returned command result describes that operation's outcome. If the original hold has since expired, an old successful response doesn't make it active again; its deadline and current state remain decisive. Use a new command for an intentional new attempt.
Each venue's committed changes also have increasing sequence numbers. A projection keeps a cursor, meaning the last sequence it has processed:
if (change.Sequence <= _cursor) return;
if (change.Sequence != _cursor + 1)
throw new InvalidOperationException("Stream gap: fetch missing events or rebuild before advancing the cursor.");
An older event is a duplicate or already covered by a snapshot. A later event with a missing predecessor is a gap, not permission to skip ahead. Catch up from the journal or rebuild the window with a consistent snapshot and cursor.
The prototype journal and deduplication records are in memory. Production needs durable command results and a transactional outbox: save the business change and its pending event in the same transaction, then deliver the event from that stored record. Microsoft's outbox explanation illustrates that commit boundary. Consumers must still tolerate repeated delivery.
Keep deduplication retention separate from the availability window. Dropping a past command key when its grid cells leave the cache could let an old retry behave like a new operation.
This also doesn't require making the event log the only source of booking truth. Ordinary booking tables plus reliable change publication can maintain a projection. Event sourcing is the further choice to reconstruct authoritative business state from an event history; it brings additional design commitments.
Invalidate appointment starts rather than just occupied cells
A resource cell becoming busy at 14:00 affects more than appointments starting at 14:00. A one-hour treatment starting at 13:30 overlaps that cell too.
For a fixed occupied duration L, an appointment starting at a uses [a, a + L). It intersects a changed interval [s, e) when:
a < e AND a + L > s
Therefore: s - L < a < e
On a grid of size g, with aligned boundaries and duration, the affected starts run from s - L + g through e - g inclusive. For a change from 14:00 to 14:10, a 60-minute occupancy and a five-minute grid, starts from 13:05 through 14:05 may change. A 13:00 appointment ends at 14:00 and doesn't overlap the changed range.
That's the region to refresh if you maintain a service-specific start-time cache. Use each resource's actual occupied duration: the technician and station can have different reset allowances.
The runnable sample maintains resource cells and checks proposed bundles directly. It doesn't maintain those additional start maps. Once measurements justify one, a bitset—one bit per possible start—can compactly mark usable starts. For an appointment of m cells, all m consecutive capacity cells must have enough free units. Combine the resulting technician, station and client start maps, after eligibility filtering, to obtain feasible bundle starts.
A simple boolean “busy” bit is insufficient for the two-seat bench. Build the mask from Capacity - Used >= requestedUnits, so the second seat remains usable.
Client blocks and skill changes are eligibility changes, not necessarily occupancy changes. They can invalidate offers even when resource counts remain identical. Keep the Part 2 policy checks at hold and confirmation time.
Let recommendations use the live feasible set
Part 2's customer profile can remain unchanged while availability changes repeatedly. Store those learned preferences, query the current resource view, then rank the feasible results.
A hold by another client might remove Cara at one from the feasible set. Expiry might make it available again. That is an availability update, not fresh evidence that this client now prefers a different technician.
This gives us a useful streaming split:
| Change | Recalculate |
|---|---|
| Booking, cancellation or hold expiry | Affected availability and dependent appointment starts |
| Published employee hours or station capacity | Affected resource ranges and dependent offers |
| Client or service eligibility rule | Relevant eligible combinations |
| New customer preference evidence | That customer's profile or ranking inputs |
| Time moves forward | Evicted head, hydrated tail and due holds |
We can cache popular date/service queries at a known version or track their resource dependencies. Personalised recommendations can be ranked from those current candidates when requested, avoiding a fan-out rebuild for every client whenever one seat changes.
A temporary hold also isn't the same outcome as an attended appointment. Keep those facts distinct when feeding the recommender described in From Behaviour to Recommendations.
Check the behaviour and the amount of work
The demo deliberately delays the read view so readers can see why the writer's recheck matters:
Cached view before hold: Available
Alice's hold: Held
Delayed view still says: Available
Bob's competing hold: NoCapacity
Caught-up view: Unavailable
Confirm exactly at expiry: HoldNotActive
After expiry: Available
Bob confirms: Confirmed
Confirmed usage remains: Unavailable
Day-three query before window moves: OutsideWindow
Day-three query after window moves: Unavailable
Tail cells written: 576
The window is two days wide and advances by one day over two resources. The entering day needs 288 × 2 = 576 explicit cell writes. A reservation made beyond the old horizon is correctly present in that new tail.
The relevant costs are:
| Operation | Work in this representation |
|---|---|
| Hold acquisition | Inspect and update the demanded resource cells |
| Streamed occupancy change | Update affected cells that intersect the read window |
| One-slot window advance | Load one entering cell per existing resource, plus pending changes |
| Due hold expiry | Pop due queue entries and release their resource cells |
| Proposed appointment check | Inspect its demanded cells |
| Full window rebuild | Load every resource cell in the new window |
Finding all appointment offers still requires choosing resource combinations and starts. The grid makes each capacity check cheap; it doesn't make every possible query constant-time. For sparse calendars or sub-grid precision, interval indexes may be a better representation. Benchmark against actual resource counts, durations, change rates and query patterns.
The checks cover 32 concurrent hold attempts, exact expiry boundaries, idempotent retries, stale expiry entries, cancellation, whole-bundle accounting, capacity edits, duplicates, stream gaps, quiet-period expiry and asynchronous consumption. Five hundred generated update steps compare the streamed view with an independent recount of active reservation records. Further cases cover bookings crossing the horizon and pending events during rollover.
The authority, journal and command results remain in memory in this sample. Rebuild tests recover the projection while that authority is still present; they don't demonstrate business-data recovery after process loss. Persist those facts and define retention separately when moving beyond this logic exercise. We continue to leave UX, payments, hold renewal and amendment workflows for later.
The change from the daily job is precise: maintain a bounded resource view from committed changes, let the clock advance it, and check ownership of capacity when a customer acquires or confirms a hold. Rebuild the view when useful, while ordinary changes update the parts they actually affect.