NAME

Shared::Arena::Ring - a record queue several processes can write at once

VERSION

Version 0.03

SYNOPSIS

my $arena = Shared::Arena->create(size => 8 * 1024 * 1024);
my $ring  = $arena->ring('events', slots => 4096, slot_size => 512);

# any number of processes, no lock between them
$ring->publish('order.paid', $payload);

# each reader has its own position and sees everything
my $cursor = $ring->cursor;
for my $rec ($cursor->drain) {
    my ($topic, $payload, $seq) = @$rec;
}

DESCRIPTION

A ring lives in an arena and is written by as many processes as care to, without a lock and without a syscall. Readers are independent: each has its own cursor, each sees every record, and a slow one never blocks a writer.

Get one from $arena->ring($name). Every process can ask for the same name with the same arguments; the first creates it and the rest attach.

A ring has a fixed size, and forgets

The ring holds a fixed number of records. When it fills, the oldest is overwritten: a publisher never blocks and never fails because a reader is behind.

Nothing is lost quietly. A reader that has been overtaken discovers it, skips to the oldest record still present, and adds what it missed to its own count. For any cursor, at any moment,

delivered + lapped + abandoned == published

so a caller can always say exactly how much it did not see. Size the ring for the slowest reader you are willing to serve, and watch lapped.

What a record can carry

A record is a topic and a payload, both arbitrary bytes, both allowed to be empty. Neither is inspected: a topic is only a label the reader may look at.

A record larger than one slot is carried across several. One larger than half the ring is refused, and publish returns -1. It is never truncated: a truncated record arrives with the right sequence and the right topic and a body that silently is not what was sent, which a reader has no way to detect.

Ask max_record for the ceiling rather than working it out. It is the total of topic and payload together, it depends on how the ring was configured, and a caller that hard-codes it starts refusing records the day somebody changes the configuration.

Records arrive in order, and whole

Every record is given a sequence when it is published, and cursors deliver in sequence order. A single publisher's records therefore always arrive in the order it wrote them, however much of the stream around them was lost.

A record is delivered whole or not at all. There is no state in which half of one and half of another arrive together.

METHODS

publish

my $seq = $ring->publish($topic, $payload);

Returns the record's sequence, which is always greater than zero. Returns 0 when there is no usable ring, and -1 when the record was refused for size.

One value rather than a pair, and deliberately not a context-sensitive one: is($ring->publish(...), 1) would call it in list context and compare its two return values against each other.

cursor

my $cursor = $ring->cursor;
my $cursor = $ring->cursor(from_start => 1);

A new reader, starting at now: it sees what happens next and does not replay what it missed. That is what a tail wants, and a process attaching to a busy ring is almost always asking about the future.

from_start => 1 begins at the oldest record the ring still holds.

Every cursor is independent. Two of them see the same records; neither consumes anything from the other.

group

my $g = $ring->group('workers');
my $g = $ring->group('workers', topic => 'jobs');

A Shared::Arena::Ring::Group: the same ring read as a queue rather than a broadcast. A cursor lives in the process that made it, so every reader sees every record; a group's cursor lives in the ring, so each record goes to exactly one member of the pool.

for my $rec ($g->claim(max => 8)) { ... }   # nobody else got these

Use a cursor when every worker needs to know, and a group when the work needs doing once. Both can read one ring at the same time without interfering.

At most once: a member that claims a record and then dies loses it. See "At most once, which is a data-loss decision" in Shared::Arena::Ring::Group.

max_record

my $bytes = $ring->max_record;

The largest topic and payload together that this ring will carry. Ask, do not assume.

slot_bytes

my $bytes = $ring->slot_bytes;

What fits in a single slot. A record within this uses one slot; a larger one spans several. Useful for sizing a ring, and of no interest at publish time.

slots

my $n = $ring->slots;

stats

my %s = $ring->stats;   # published, oversize, seq

Counted across every process, for the life of the ring. seq is the next sequence to be handed out.

CONFIGURING A RING

slots and slot_size are given once, by whoever creates it, and every later attacher must ask for the same shape or be refused.

my $ring = $arena->ring('events', slots => 4096, slot_size => 512);

slots is how many records the ring holds when they are small, and it is what decides how far behind a reader may fall. slot_size is the space one slot gives to a topic and payload plus a small header; make it comfortably larger than a typical record and let the unusual ones span.

Both cost memory whether used or not: a ring occupies slots * slot_size bytes for its life.

SEE ALSO

Shared::Arena, Shared::Arena::Ring::Cursor.

AUTHOR

LNATION, <email at lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION.

This is free software, licensed under the Artistic License 2.0.