Four distinct failure modes, only one of which is designed. Each now fails in a
named, recoverable way — this describes what the code actually does today, including the gaps
that remain. Code shown is from UltraDict2/UltraDict2.py.
UltraDict2 keeps a dict in POSIX shared memory across processes. Three separate shared
segments are involved, and they exhaust in three different ways, with a fourth mode where
nothing is exhausted at all but a reader still fails. Knowing which one you are looking at is
the difference between raising buffer_size and hunting a leak.
A dict named N owns a fixed 1000-byte control segment, an
update buffer named N_memory, and one or more
full dump segments. Writes normally append a serialized frame to the update
buffer. When that buffer cannot fit the next frame, the whole dict is serialized into a full
dump segment instead and the buffer is reset to empty.
Named after the dict itself. Holds the buffer fill level, the dump counter and the name of the current dump segment. Every process reads it constantly.
buffer_size, fixedNamed <dict>_memory. An append-only stream of serialized changes.
Never wraps — it is reset to empty by a full dump.
full_dump_size, or sized per dumpA complete snapshot of the dict. With no static size, a fresh segment is allocated for every dump and the previous one unlinked.
The control segment is the coordination point. Its layout is packed into the first 284 bytes; the remaining 716 bytes are unused today.
| Bytes | Field | Purpose |
|---|---|---|
| 0:4 | update_stream_position | How full the update buffer is. This is the authoritative fill level. |
| 4:8 | lock_pid | PID of the current lock holder. |
| 8:10 | lock | Lock state. |
| 10:14 | full_dump_counter | Incremented on every dump. Readers compare against their local copy. |
| 14:18 | full_dump_static_size | Non-zero only when a static full_dump_size was configured. |
| 18:19 | shared_lock | Flag. |
| 19:20 | recurse | Flag. |
| 20:275 | full_dump_memory_name | Name of the current dump segment, so readers can find it. |
| 275:283 | lock_time | Lock acquisition timestamp. |
| 283:284 | ready | Creator finished initialising. |
| 284:1000 | — | Unused. |
This is the common case and it is not an error. append_update() serializes the
change, then checks whether the frame fits:
if end_position > self.buffer_size: # :854
self.apply_update()
if not delete:
self.data.__setitem__(key, item)
self.dump()
return
Note what this costs. A write that overflows does not get appended to the stream at all — it
reaches the other processes only inside the full dump. So a 20-byte write to a 500 MB dict can
trigger a 500 MB serialization. Peers then discover the new
full_dump_counter and each reloads the entire dict.
sequenceDiagram
autonumber
participant W as Writer
participant B as Update buffer
participant C as Control
participant D as Dump memory
participant R as Reader
W->>B: append frame
W->>C: position += frame size
R->>C: read position
R->>B: replay new frames
Note over W,B: next frame does not fit
W->>W: apply_update then set locally
W->>D: serialize the whole dict
W->>C: publish dump name
W->>C: full_dump_counter += 1
W->>C: position = 0
R->>C: counter changed
R->>D: load full dump
R->>R: discard local stream position
buffer_full_forced_dump_total climbing; buffer_used_fraction sawtoothing to near 1 and back to 0.buffer_size until the rate drops to something you are happy paying.Only reachable with a static full_dump_size. That segment is allocated once at
construction and is never resized, so once the dict outgrows it, dump() raises:
if length + 6 > full_dump_memory.size:
self.full_dump_memory_full += 1
raise Exceptions.FullDumpMemoryFull(...)
The exception propagates out through dump(), append_update() and
__setitem__ to your code. What matters is the state it leaves behind, and that used
to be bad. The overflow branch in §1 publishes nothing to the stream — the change rides
entirely on the dump — so a failed dump once left the writer holding an item that existed in
exactly one process and nowhere else, with the buffer still full so every later write raised
again. Permanently wedged, silently divergent.
The local mutation now lives inside append_update, which owns the publish. It
snapshots what it is replacing, applies the change, and puts it back if the dump does not
happen:
had_key = key in self.data
old_item = self.data.get(key)
...
try:
self.dump()
except Exception:
if had_key:
self.data.__setitem__(key, old_item)
else:
self.data.pop(key, None)
raise
The snapshot is taken after the branch's own apply_update(), so it
reflects what peers see rather than a pre-reload guess, and dump() raises before
writing a single remote byte. So the failure is now atomic: the write either lands everywhere
or nowhere.
It also self-heals. Because dump() serializes
self.data after the speculative mutation, a del dumps the
reduced dict — so deleting your way out of a full dict works. What does not recover is
a dict that exceeds full_dump_size even at its smallest: that keeps raising on
every mutation. But the dict stays consistent while stuck, and reads keep working.
flowchart TD
A["__setitem__"] --> B["append_update serializes frame"]
B --> C{"frame fits in buffer?"}
C -->|yes| D["apply locally, write frame, bump position"]
D --> E["peers see it"]
C -->|no| F["snapshot old value, apply locally"]
F --> G["dump serializes whole dict"]
G --> H{"dict fits in dump memory?"}
H -->|yes| I["publish dump, reset position"]
I --> E
H -->|no| J["restore the snapshot"]
J --> K["raise FullDumpMemoryFull"]
K --> L["local copy matches peers"]
UltraDict2.Exceptions.FullDumpMemoryFull on the write that did not fit. The write is rolled back, so nothing is lost or hidden.full_dump_memory_full_total non-zero. Watch item_size_bytes_sum against full_dump_size_bytes to see it coming.full_dump_size for the largest dict you will ever hold rather than the current one. On Linux, prefer omitting it entirely so dumps size themselves.With no static full_dump_size, every dump allocates a brand-new segment sized to
fit, then unlinks the previous one:
full_dump_memory = self.get_memory(create=True, size=length + 6) # :693
...
if old and old != full_dump_memory.name and not self.full_dump_size:
self.unlink_by_name(old) # :740
Steady state is therefore two live dump segments at most. But the allocate-then-unlink order
means a process that dies between those two lines leaks the old segment permanently. Nothing
reclaims it: /dev/shm entries survive the process that made them. Repeat that under
a crash loop and tmpfs fills.
When it does, SharedMemory(create=True, ...) raises a plain OSError.
That used to escape unwrapped, making "the machine is out of shared memory" indistinguishable
from any other OS error — arriving from an ordinary-looking d[k] = v. It is now
caught and re-raised as Exceptions.CannotCreateSharedMemory, with a message naming
the actual remedies and the original error kept as __cause__.
Note the filter is the exception type, not the errno. Errno is not dependable here:
an exhausted Windows paging file reports EINVAL, not ENOSPC, so an
errno allowlist would miss the most likely real failure. Since
SharedMemory(create=True) can only fail for host-resource reasons or a name
collision, and the collision is handled separately, catching every OSError at that
call is the precise filter.
OverflowError is caught alongside it. On a 32-bit build a size beyond
ssize_t is rejected by mmap before the OS is ever asked — the segment
still cannot be created, and reporting that as an arithmetic error from deep inside
mmap tells the caller nothing useful.
flowchart TD
A["dump needs memory"] --> B["allocate new segment sized length + 6"]
B --> C["write dump, publish name"]
C --> D["unlink previous segment"]
B -.->|"process dies here"| E["previous segment leaked"]
E --> F["tmpfs usage grows"]
F --> G["shm_open returns ENOSPC"]
G --> H["CannotCreateSharedMemory from __setitem__"]
CannotCreateSharedMemory from a dict write, wrapping the host's OSError. Other processes on the host fail too, not just yours.shm_free_bytes trending down and not recovering. Alert well before zero.ls -la /dev/shm. Anything matching your dict names with no owning process is leaked and can be removed with UltraDict.unlink_by_name(name, ignore_errors=True).Nothing is exhausted here. The buffer is being recycled faster than a slow reader can replay it. Readers replay the stream without a lock, so a dump can reset the buffer under a reader mid-replay. Two guards catch it, and both recover by starting over.
Those retries used to be unbounded. A reader that could never catch up recursed until
RecursionError — raised from an ordinary d['x'], len(d) or
iteration, since all of them enter apply_update(). Both paths now carry a retry
count, following the bounded pattern get_full_dump_memory() already used: three
lock-free attempts, then a final one under the lock, so the last try is exclusive
rather than another dice roll.
if retry < max_retry:
return self.load(force=True, max_retry=max_retry, retry=retry + 1)
elif retry == max_retry:
# On the last retry, take the lock so nobody can dump while we read
with self.lock:
return self.load(force=True, max_retry=max_retry, retry=retry + 1)
raise Exceptions.FullDumpsTooFast(...)
The recursion is deliberately kept rather than rewritten as a loop: the final
apply_update retry runs while holding the lock, and a continue would
release it before retrying, quietly deleting that guarantee.
One subtlety worth knowing if you touch this code: FullDumpsTooFast must
not subclass AssertionError. CorruptedStream does,
precisely so the recovery handlers catch it — so a give-up signal that also inherited from
AssertionError would be caught by the very handlers raising it and retried forever.
The fix for infinite recursion would itself recurse infinitely.
The warning is still the leading signal: it means you are close to the bound.
sequenceDiagram
autonumber
participant W as Writer
participant C as Control
participant R as Reader
R->>C: read position, begin replay without lock
W->>C: dump, counter += 1, position = 0
W->>C: dump again, counter += 1
R->>R: unpickle garbage, CorruptedStream
R->>C: counter moved, so recover
R->>R: apply_update again, retry 1 of 3
Note over R: writer may have dumped again already
R->>R: final retry runs under the lock
R->>R: still behind, raise FullDumpsTooFast
Full dumps too fast warnings in reader logs, then FullDumpsTooFast from an ordinary read once the retries are spent.full_dump_too_fast_total climbing on readers, correlated with buffer_full_forced_dump_total on the writer.buffer_size to cut the dump rate, or make readers poll more often so they have less to replay.On Linux a shared segment outlives the process that created it, so the writer can close its handle right after dumping. On Windows the mapping is destroyed when the last handle closes, so the code deliberately holds the handle open instead:
if not self.full_dump_size and sys.platform != 'win32': # :714
full_dump_memory.close()
The consequence is that on Windows, a dynamically sized dump lives only as long as the process
that wrote it. If that process exits, later readers get
CannotAttachSharedMemory. That is why a static full_dump_size is
recommended on Windows — and it is exactly the configuration that makes §2 reachable. The two
platforms trade one failure mode for the other.
Also note buffer_size and full_dump_size are rounded up to a 4096
multiple on Windows, so buffer_size=10_000 reports as 12288.
macOS is a third case: it is POSIX and shared memory works, but there is no
/dev/shm directory to measure, so every shm_* metric is
None there, as on Windows.
| Failure | Leading indicator | Confirms it happened |
|---|---|---|
| §1 Buffer fills | buffer_used_fraction approaching 1 |
buffer_full_forced_dump_total |
| §2 Dump memory too small | item_size_bytes_sum vs full_dump_size_bytes |
full_dump_memory_full_total |
| §3 System shm exhausted | shm_free_bytes trending down |
CannotCreateSharedMemory |
| §4 Reader starvation | full_dump_too_fast_total rising |
FullDumpsTooFast |
All of these come from ultra.get_metrics(). See the Metrics section of the
readme for the Prometheus collector.
tests/performance/buffer_size_sweep.py sweeps buffer_size against a
fixed payload and write pattern. Measured on a 100 KB dict in 200 keys, 2000 writes of 50 bytes,
one reader following along:
| buffer_size | writes/sec | dumps/1k writes | buffer used | amplification |
|---|---|---|---|---|
| 4,096 | 65,650 | 20.0 | 0.0% | 16.1x |
| 16,384 | 97,708 | 5.0 | 46.0% | 4.0x |
| 65,536 | 119,055 | 1.5 | 14.4% | 1.2x |
| 262,144 | 125,654 | 0.5 | 3.8% | 0.4x |
| 1,048,576 | 131,191 | 0.0 | 25.9% | 0.0x |
Amplification is bytes moved per byte of real change, counting the writer only — multiply by the number of reader processes. The knee is at 64 KiB for this shape: the undersized buffer moves 16x more bytes than the change is worth and runs at 45% of the throughput.
Read the third column carefully. The worst row reports the buffer as 0.0% used.
A buffer too small resets on every dump, so it looks idle exactly when it is thrashing.
buffer_used_fraction is only meaningful once the buffer is adequate — alert on
buffer_full_forced_dump_total instead.
buffer_size must exceed item_size_bytes_max plus six bytes, or every single write dumps.writes_between_acceptable_dumps × avg_frame_size.Three known gaps remain, each deliberately out of scope so far:
recurse=True, assigning a
nested dict builds a child UltraDict — creating shared segments and registering
its name — before the parent's own write is published. If that write then fails, the child
segment and its registry entry persist. Unwinding them would mean unlinking a segment a peer
may already have attached to, so doing nothing is the safer bug. The leak is bounded:
unlink_recursed() sweeps the register when the parent closes.update() is not atomic across keys. It loops
self[k] = v, publishing each key separately, so a failure part-way leaves earlier
keys visible to peers. Individual keys are never torn — only the batch. Real batch atomicity
needs a multi-key frame format.dump(). The new
segment's name is written to the control block before the dump counter is incremented, so
there is a brief window advertising a new segment under the old generation. The file's own
TODO flags it.