Tinverse logo Tinverse LLC

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

Runs when

the deployment is configured, before anything starts

How often

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.

LimitPartBoundsAt the limit
Remote participants, writers, readers1Discovery tablesNew identity refused; an existing one may still refresh
Local endpoints and matches1The matching graphEndpoint creation fails before an identifier is spent
Partition names and lengths1The comparison inside matchingRejected when the endpoint is registered
Collection capacity2Storage inside each sampleAnnounced count above it is malformed input
String length2Bytes borrowed from an inputRejected before anything is reserved
Assemblies in progress3Partly received samplesNew assembly refused; existing ones untouched
Sample size and piece count3One reassembly bufferRefused before the buffer is reserved
Repair retry budget3Requests to a silent peerRequests stop; restored by any new piece
History depth4Samples kept per instanceOldest dropped, or new one refused — see below
Instance count4Distinct keys trackedSample refused without half-inserting a key
Log record size4One durability frameRefused at write; recovery refuses to read one
Slots and size classes5The shared regionReservation reports empty; writer falls back
Owner records per slot5Who may hold one payloadFixed at 16; reclaim keeps a slot it cannot account for
Reader queue depth5Undelivered notificationsPublication abandoned before any queue is written
Handshake sessions6Peers authenticating at onceEnrolment refused; sessions in progress unaffected
Replay window6Message numbers rememberedOlder than the window is refused as unprovable
Grants and rules6One permissions documentExtraction fails closed
Serialization blocks7Concurrent writersWriter creation fails; the pool is unchanged
Receive slots7Datagrams drained per turnFilling stops; the rest wait for the next turn
Duplicate window7Command identities rememberedOldest 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

Runs when

any bounded structure is asked to accept work

How often

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

  1. if not Well-Formed(work)
  2. return error // cheapest test first
  3. n = Cost-In-Slots(structure, work)
  4. if Occupancy(structure) + n > Capacity(structure)
  5. return resource-limit
  6. for each derived table d of structure
  7. if Occupancy(d) + Cost-In-Slots(d, work) > Capacity(d)
  8. return resource-limit // still nothing written
  9. Apply(structure, work) // the first irreversible step
  10. Update-Derived(structure, work)
  11. 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.

CheckValueOutcome
Endpoint table60 + 1 ≤ 64passes
Match table126 + 4 > 128fails

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

Runs when

a sample is admitted to a reader whose history has a per-instance limit

How often

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.

history for one instance, limit 3, full 7 8 9 sample 10 arrives keep last 7 8 9 10 oldest dropped, silently — the application asked for the newest keep all 7 8 9 sample 10 refused, and the loss is reported
Figure 13.1 The same full history, two behaviours. Which sample is lost depends on which the application said it wanted.

Admit-To-History(reader, sample)

reader with its bounded history, per-instance limit, and chosen behaviour · sample the arriving sample and its instance key

  1. n = reader.count[sample.key] // kept up to date, not counted here
  2. if n < reader.max-per-instance
  3. Append(reader.history, sample)
  4. reader.count[sample.key] = n + 1
  5. return ok
  6. if reader.behaviour == keep-all
  7. return resource-limit // the new sample is the one lost, and it is reported
  8. oldest = First-Of-Instance(reader.history, sample.key) // stops at the first match
  9. Remove(reader.history, oldest)
  10. Append(reader.history, sample)
  11. 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

Runs when

an acceptance run starts and is told to pick its own ports

How often

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

  1. for each base in candidates
  2. held = ⟨⟩
  3. ok = true
  4. for each d in offsets
  5. s = Bind(base + d)
  6. if s == error
  7. ok = false; break
  8. Append(held, s) // kept open so nothing else can take it
  9. Close-All(held)
  10. if ok
  11. return base
  12. 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

Runs when

an acceptance run is asked for

How often

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.

Part 14 →

These descriptions follow the implementations in Tin DDS and Retina. Evaluation access and the full algorithm notes are available from engineering@tinverse.com.