NAME

Acme::Parataxis::Local - Per-fiber Storage Slot

SYNOPSIS

use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::Local;

my $request_id = Acme::Parataxis::Local->new;
my $trace_id   = Acme::Parataxis::Local->new( inherit => 1 );    # copied into spawned children

async {
    $request_id->set(42);
    $trace_id->set('span-1');

    fiber { $request_id->set(99); say $request_id->get };        # 99
    fiber { say $trace_id->get };                                # span-1
    say $request_id->get;                                        # 42
};

$request_id is walled off: the child writes 99 and the parent still reads 42. $trace_id was inherited, so the child starts from the parent's value without being handed one. The three lines print in that order, because a fiber body begins running at the fiber call rather than after it.

DESCRIPTION

A Local is one named variable that reads differently depending on which fiber asks. Parent, children and siblings each get a private cell, so no fiber can read or overwrite another's value:

my $depth = Acme::Parataxis::Local->new( default => 0 );

async {
    $depth->set(1);
    fiber { say $depth->get };        # 0 - the child starts from the default
    say $depth->get;                  # 1
};

This is the opposite of local: the value follows the fiber rather than the dynamic call stack, and it survives any number of awaits and yields in between without being passed down by hand.

Create one Local per logical variable - a trace id, a request id, a per-fiber database handle, a re-entrancy flag - and none of it needs cooperation from the scheduler or the C core. Each fiber's cell is dropped when that fiber finishes and is reaped, so a long-running program does not accumulate stale values.

Inheritance

A slot created with inherit => 1 opts into trace propagation: at the moment a child fiber is spawned, each such slot's current value is copied out of the spawning fiber into the child's own cell, before the child's body runs its first statement. That is how a request id or a logging context reaches work you would rather not thread arguments through.

my $trace = Acme::Parataxis::Local->new( inherit => 1 );

async {
    $trace->set('span-1');
    fiber { say $trace->get };    # span-1
};

The copy is shallow, and the child owns it outright from that moment. Later sets in the child never reach the parent, and later sets in the parent never reach the child.

Propagation happens at the three spawn points that go through spawn: fiber and spawn, a nursery child, and an actor's own fiber. It does not happen for an async or run block - see "Behavior Notes".

METHODS

new( [ default => $default, inherit => 0 ] )

my $plain  = Acme::Parataxis::Local->new;
my $seeded = Acme::Parataxis::Local->new( default => 'unset' );
my $traced = Acme::Parataxis::Local->new( inherit => 1 );

Creates a slot. Both arguments are named and optional; default is undef and inherit is false unless you say otherwise. default is what get returns in a fiber that has not written to the slot yet, and inherit opts the slot into "Inheritance". Any true value works for inherit.

Arguments are keyword only. Acme::Parataxis::Local->new('x') is rejected with Unrecognized parameters for "Acme::Parataxis::Local" constructor: x, as is any key that is not default or inherit.

get( )

fiber { say $request_id->get };    # 99

Returns the calling fiber's value, or the slot's default if that fiber has never written to it. Never exposes another fiber's value, in either direction.

Croaks with Local-get must be called from inside a scheduled fiber> when called outside a fiber, so a missing async is a loud error rather than a silent undef.

set( $value )

$request_id->set(42);

Writes $value into the calling fiber's cell and returns it. The write is private to this fiber and is never visible to its parent or its siblings.

Croaks with Local-set must be called from inside a scheduled fiber> when called outside a fiber.

Recipes

Carry a request id into work you cannot pass arguments to

my $request_id = Acme::Parataxis::Local->new;

async {
    my $id = $request_id->get // 'anonymous';

    fiber {
        # ... a deep call chain that never mentions $id, but every line of it can log it
        log_line( request => $id );
    };
};

Give each fiber its own connection handle

my $dbh = Acme::Parataxis::Local->new;

fiber {
    my $handle = connect();
    $dbh->set($handle);

    # any helper reached from here can reach for the handle without it being passed along
    $dbh->get->do_something;
};

Because the cell is private, two fibers working at once never share a handle, and nothing has to be locked.

Assert isolation in a test

my $slot = Acme::Parataxis::Local->new( default => 'empty' );

my $ok = eval {
    async {
        $slot->set('mine');
        fiber { die 'a child saw ' . $slot->get if $slot->get ne 'empty' };
    };
    1;
};

die $@ unless $ok;

Behavior Notes

async does not propagate inherited values

An async block (or run) does run its block in a fiber of its own, so an ordinary Local is isolated from it as usual. But async builds that fiber directly rather than through spawn, and the inheritance seed lives in spawn. That produces the one asymmetry in this class:

my $trace = Acme::Parataxis::Local->new( inherit => 1 );

async {
    $trace->set('root');
    async { say $trace->get };     # undef - not inherited
    fiber  { say $trace->get };     # root
};

To carry a value across an async boundary, pass it in explicitly or move the work into a fiber.

default is only a fallback for an unwritten cell

A fiber that has written anything gets exactly what it wrote, and undef counts as a write:

my $slot = Acme::Parataxis::Local->new( default => 'dflt' );

async {
    say $slot->get;        # dflt
    $slot->set(undef);
    say $slot->get;        # undef, not dflt
};

A value lives exactly as long as its fiber

A cell belongs to a fiber, not to the slot. When that fiber finishes and is reaped, its stash is deleted and the value goes with it, whether or not a Local object is still in scope. Holding the object alive does not resurrect it. A new fiber starts from default again.

Each object is a separate variable

Two Local objects never share a value, whatever their arguments; there is no name to collide on and no identifier exposed to compare. Create as many as the program needs.

How It Works

Each Local takes a process-wide monotonic id at construction, so two slots never collide and a recycled memory address cannot alias a newer slot. get and set look up the calling fiber's stash - a plain hash addressed by the fiber object's refaddr - and index it by that id, falling back to default when the key is absent.

An inherit => 1 slot pushes its id onto @Acme::Parataxis::INHERIT_LOCAL_IDS at construction and removes it again when the Local is destroyed. spawn reads that list, snapshots the parent's matching cells before the child body gets control, and wraps the child so the snapshot lands in the child's own stash on its first instruction. When nothing is set, the snapshot is empty and no wrapper is built, so the ordinary spawn path costs nothing extra.

SEE ALSO

Acme::Parataxis, Acme::Parataxis::Actor, Acme::Parataxis::Nursery

AUTHOR

Sanko Robinson https://github.com/sanko

LICENSE

Copyright (C) Sanko Robinson.

This library is free software; you can redistribute it and/or modify it under the terms found in the Artistic License 2.