Part 13 of 14
Resource limits
Every part of this series has said that something is bounded by configuration. Those bounds are what makes the memory a participant uses a property of the deployment rather than of whatever arrives on the network, and they are the reason none of the earlier algorithms can be made to allocate without limit by a peer.
A limit is only useful if reaching it is well defined. Two things have to be true: the work has to be refused rather than partly done, and the refusal has to leave the system exactly as it was.
Configuration
What is bounded
the deployment is configured, before anything starts
Once. Every limit below is fixed for the life of the participant.
These are the limits the earlier parts refer to, and what happens at each one.
| Limit | Part | Bounds | At the limit |
|---|---|---|---|
| Remote participants, writers, readers | 1 | Discovery tables | New identity refused; an existing one may still refresh |
| Local endpoints and matches | 1 | The matching graph | Endpoint creation fails before an identifier is spent |
| Partition names and lengths | 1 | The comparison inside matching | Rejected when the endpoint is registered |
| Collection capacity | 2 | Storage inside each sample | Announced count above it is malformed input |
| String length | 2 | Bytes borrowed from an input | Rejected before anything is reserved |
| Assemblies in progress | 3 | Partly received samples | New assembly refused; existing ones untouched |
| Sample size and piece count | 3 | One reassembly buffer | Refused before the buffer is reserved |
| Repair retry budget | 3 | Requests to a silent peer | Requests stop; restored by any new piece |
| History depth | 4 | Samples kept per instance | Oldest dropped, or new one refused — see below |
| Instance count | 4 | Distinct keys tracked | Sample refused without half-inserting a key |
| Log record size | 4 | One durability frame | Refused at write; recovery refuses to read one |
| Slots and size classes | 5 | The shared region | Reservation reports empty; writer falls back |
| Owner records per slot | 5 | Who may hold one payload | Fixed at 16; reclaim keeps a slot it cannot account for |
| Reader queue depth | 5 | Undelivered notifications | Publication abandoned before any queue is written |
| Handshake sessions | 6 | Peers authenticating at once | Enrolment refused; sessions in progress unaffected |
| Replay window | 6 | Message numbers remembered | Older than the window is refused as unprovable |
| Grants and rules | 6 | One permissions document | Extraction fails closed |
| Serialization blocks | 7 | Concurrent writers | Writer creation fails; the pool is unchanged |
| Receive slots | 7 | Datagrams drained per turn | Filling stops; the rest wait for the next turn |
| Duplicate window | 7 | Command identities remembered | Oldest overwritten — not a fault |
Two rows behave differently from the rest, and both are deliberate. A full duplicate window overwrites rather than refusing, because being full is its normal condition. A history may drop its oldest sample rather than refusing the new one, depending on which behaviour the application asked for.
Discipline
Rejecting without side effects
any bounded structure is asked to accept work
Continuously — this is the shape of nearly every procedure in the series.
The same ordering appears in every part, and it is worth naming once. The step that cannot be undone comes last, after every step that can still refuse.
The reason is that partial application is what makes limits leak. A structure that admits three of five samples and then discovers it is full has to undo two, and undoing is where the bugs are: the state to restore has to be remembered, the restore path runs rarely so it is poorly tested, and it can fail too.
Bounded-Admit(structure, work)
structure any of the bounded tables, queues, or pools above · work what it is being asked to accept
- if not Well-Formed(work)
- return error // cheapest test first
- n = Cost-In-Slots(structure, work)
- if Occupancy(structure) + n > Capacity(structure)
- return resource-limit
- for each derived table d of structure
- if Occupancy(d) + Cost-In-Slots(d, work) > Capacity(d)
- return resource-limit // still nothing written
- Apply(structure, work) // the first irreversible step
- Update-Derived(structure, work)
- return ok
Line 6 is the part that is easy to leave out. A structure is rarely alone: admitting an endpoint also creates matches, admitting a sample also occupies an instance record, publishing also consumes a queue entry in every reader. Checking only the structure being written leaves the derived ones to fail after the point of no return.
Worked example
A participant configured for 64 endpoints and 128 matches currently holds 60 endpoints and 126 matches. A new reader arrives that would match four existing writers.
| Check | Value | Outcome |
|---|---|---|
| Endpoint table | 60 + 1 ≤ 64 | passes |
| Match table | 126 + 4 > 128 | fails |
The reader is refused, and the endpoint table still holds 60. Had the endpoint been inserted on the strength of the first check alone, the participant would be left holding an endpoint that can match nothing, occupying a slot, announced to peers, and matching zero writers for reasons no log would explain.
History
When a history is full
a sample is admitted to a reader whose history has a per-instance limit
Once per admitted sample.
History is the one limit where refusing is not always the right behaviour, because the two things an application might want are incompatible.
An application tracking a robot's current position wants the newest sample and does not care about the one before it; making it wait while an old position occupies the last slot is useless. An application processing a command stream wants every sample, and silently dropping the oldest would lose a command.
So the behaviour is chosen: keep the last n and discard beyond that, or keep everything and refuse when full.
Admit-To-History(reader, sample)
reader with its bounded history, per-instance limit, and chosen behaviour · sample the arriving sample and its instance key
- n = reader.count[sample.key] // kept up to date, not counted here
- if n < reader.max-per-instance
- Append(reader.history, sample)
- reader.count[sample.key] = n + 1
- return ok
- if reader.behaviour == keep-all
- return resource-limit // the new sample is the one lost, and it is reported
- oldest = First-Of-Instance(reader.history, sample.key) // stops at the first match
- Remove(reader.history, oldest)
- Append(reader.history, sample)
- return ok
Line 1 is the difference between this being cheap and being the most expensive thing on the delivery path. The obvious implementation counts by walking the history, which costs the whole history on every sample. Keeping the count instead means the only walk is line 8, and that one stops at the first sample of the instance rather than visiting them all.
Line 6 returns before line 8, so a reader that keeps everything never looks for an oldest sample it is not going to remove.
Why the limit is per instance
A history counted across all instances lets one busy instance evict another's samples. A reader watching ten robots would find that the one publishing fastest crowds out the other nine, and the reader would appear to stop hearing from robots that are publishing perfectly well.
Why losing a sample is reported
Line 15 returns a resource-limit result rather than dropping quietly. Under keep-all the application asked for every sample, so failing to deliver one is a fact it needs — this is the case where the reader is not keeping up, and it cannot respond to that if it is not told.
Cost
Reading the count is O(I) over the instances being tracked, which the instance limit bounds and which never exceeds the number of samples retained. Finding the oldest of an instance is O(F) in the distance to its first sample. Neither depends on the history depth, so a reader configured to keep a thousand samples costs the same per sample as one keeping four.
The counters hold one key per tracked instance, allocated the first time an instance is seen and released when its last sample leaves, so a reader in steady state still admits without allocating.
Deployment
Choosing a free port
an acceptance run starts and is told to pick its own ports
Once per run. This is a deployment gate, not a path anything takes at run time.
A machine running an acceptance test usually has other robotics software on it, already holding the ports this run wants. Failing on a collision is correct but makes the result depend on what else the machine happens to be doing.
Given a base port, a run derives every port offset it needs, then tries to bind all of them — holding each socket open until the whole set has succeeded. Holding them is the point: binding one, closing it, and binding the next proves only that each was free at some moment, and something else may take the first while the second is being tested.
Choose-Port-Base(offsets, candidates)
offsets the port offsets this run needs · candidates the base ports to try, in order
- for each base in candidates
- held = 〈〉
- ok = true
- for each d in offsets
- s = Bind(base + d)
- if s == error
- ok = false; break
- Append(held, s) // kept open so nothing else can take it
- Close-All(held)
- if ok
- return base
- return error
An explicitly configured base skips the search and fails immediately, naming the exact address and port that was occupied. Automatic selection makes a run robust on a shared machine; an explicit one keeps a failure reproducible. Neither is right for both jobs, which is why both exist.
Cost
O(C · P) bind attempts for C candidate bases and P ports per run, both small and bounded by configuration.
Evidence
Showing the limits hold
an acceptance run is asked for
Not part of the product; it is how the claims above are demonstrated.
A configuration file states the intended capacities. It does not establish that the running code enforces them, that a refusal leaves nothing behind, or that storage returns to the pool when the last holder releases it.
So the limits are exercised rather than asserted. A run drives the same public interface an application uses — no test-only entry points — over many cycles: fill a bounded structure to its capacity, confirm the next request is refused, confirm the structure is unchanged by the refusal, release everything, and confirm the capacity is available again. Doing it repeatedly is what distinguishes a limit that is enforced from one that leaks a slot per cycle, which a single pass cannot tell apart.
The result is a report rather than a pass mark, so a specific run can be pointed at later. Optional capabilities are kept in separate scenarios, so a result about the base protocol is not quietly carried by shared memory being available.
What this establishes
A soak measures the implementation on one machine over one run. Where this series says a rejection changes nothing, that property is checked exhaustively over a model rather than measured — the two are different kinds of evidence, and neither substitutes for the other. The soak can show a limit leaking; it cannot show that none ever will.
Next: ros 2 adaptation
ROS hands over type information at run time as data, and three parts of the mapping stop being mechanical.
These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.