NAME
Acme::Parataxis::Error - Base Class for Cancellable-Wait Diagnostics
SYNOPSIS
use v5.40;
use Acme::Parataxis qw[async with_timeout];
async {
my $err = do { eval { with_timeout( 250, sub { $slow->run } ) }; $@ };
if ( ref $err and $err->isa('Acme::Parataxis::Error') ) {
say $err->kind; # 'timeout'
say $err->message; # "The operation timed out at ..."
say $err->wait_reason->[0]; # the wait that was interrupted
say "$err"; # stringifies to message
}
elsif ( $err ) {
die $err; # a real bug, not a cancellation
}
};
Telling a cancellation apart from a crash is the point of the base class:
if ( ref $err and $err->isa('Acme::Parataxis::Error') ) {
if ( $err->kind eq 'timeout' ) { retry_with_a_bigger_bound() }
elsif ( $err->kind eq 'cancelled' ) { give_up_quietly() }
else { $self->escalate( $err ) }
}
DESCRIPTION
Parataxis raises one shape of exception when a parked wait is cut short: a with_timeout deadline, a Acme::Parataxis::CancellationToken, or a tree of children that failed. Every one of those is an object, and every one of them is a subclass of Acme::Parataxis::Error. That is the whole job of the base class: a single $err->isa('Acme::Parataxis::Error') catches every diagnostic flavour, and kind says which one it was.
A cancelled wait throws before the call it was waiting for runs, so an interrupted block is not half-finished - it never started. An ordinary die inside the block is not converted into one of these; it propagates unchanged. isa is therefore a reliable test for "this was a cancellation" rather than "something broke".
Every error overloads stringification onto its message, so say $err and "failed: $err" read the way you would hope without calling anything.
The base class is not itself a usable error. It has no constructor - Acme::Parataxis::Error->new dies with Can't locate object method "new". It contributes the shared message and wait_reason accessors and the overload; the five subclasses below supply the rest. All five live in this one file, so there is no Acme/Parataxis/Error/Timeout.pm to use.
METHODS
message( )
say $err->message; # "The operation timed out at Semaphore down (t/job.pl line 42)"
The rendered message, <label> at <site>. The site is the parked wait that was interrupted, as <reason> (<file> line <line>), or the literal an unknown wait when the wait recorded no reason. Measured from a real with_timeout:
The operation timed out at await_sleep (t/job.pl line 42)
The operation was cancelled at Semaphore down (t/job.pl line 17)
kind( )
if ( $err->kind eq 'timeout' ) { ... }
The machine-readable discriminator. One of five strings: 'cancelled', 'timeout', 'nursery', 'supervisor', or 'stm_retry'. The last one is internal to software transactions and never reaches your code; see "Acme::Parataxis::Error::STM_Retry".
wait_reason( )
my ( $reason, $file, $line, $backtrace ) = @{ $err->wait_reason };
The record the interrupted wait parked with, as four elements: the wait's own reason string, the file and line it parked at, and a backtrace array ref. The first three are what message is built from, so message is wait_reason with the parentheses added. May be undef for errors that are not thrown by a wait, and for a hand-built error.
The same value is available on the fiber itself via wait_reason; see Acme::Parataxis for the backtrace's shape.
THE SUBCLASSES
Acme::Parataxis::Error::Cancelled
Acme::Parataxis::Error::Cancelled->throw( wait_reason => $reason );
Kind 'cancelled'. Thrown at the re-entry of a parked wait when a Acme::Parataxis::CancellationToken was cancelled while the fiber was waiting. Message The operation was cancelled at .... Has new and throw, and inherits message and wait_reason from the base.
Acme::Parataxis::Error::Timeout
Acme::Parataxis::Error::Timeout->throw( wait_reason => $reason );
Kind 'timeout'. Thrown at the re-entry of a parked wait when a with_timeout deadline fired before the block could finish. Message The operation timed out at .... Also has new and throw, and adds seconds - but see "The seconds accessor is never populated by the framework" before relying on it.
This is a sibling of Cancelled, not a subclass of it. Both descend directly from Acme::Parataxis::Error, so $err->isa('Acme::Parataxis::Error::Cancelled') is false for a timeout. Catch the base class and switch on kind.
Acme::Parataxis::Error::Nursery
my $err = Acme::Parataxis::Error::Nursery->new( failures => \@child_errors );
say $err->primary; # the real culprit
say $_->message for $err->failures;
Kind 'nursery'. Thrown by Acme::Parataxis::Nursery when one or more enrolled children died. failures is every child's error in spawn order; primary is the first natural one, that is, the first failure whose kind is not 'cancelled' - the real culprit rather than the siblings it brought down with it. A plain-string die counts as natural. If every child was merely cancelled, primary falls back to the first one. Neither wait_reason nor seconds applies, and there is no throw: the nursery builds this for you.
Acme::Parataxis::Error::Supervisor
my $err = Acme::Parataxis::Error::Supervisor->new( failures => \@deaths, child => $name );
say $err->child . ' blew the budget: ' . $err->primary;
Kind 'supervisor'. Thrown by Acme::Parataxis::Supervisor's run when a supervised tree exhausts its restart budget, after the tree has already been torn down. failures is every death that counted against the budget, in the order it happened, and child names the one that blew it. Same shape as the nursery error, but primary is resolved differently - see "Nursery and Supervisor disagree about which failure is primary". No throw, no wait_reason, no seconds.
Acme::Parataxis::Error::STM_Retry
# internal: never written by your code
Acme::Parataxis::Error::STM_Retry->new; # message: "STM retry (internal)"
Kind 'stm_retry', and the one kind you should never see. retry inside an atomically block throws this, and atomically itself catches it: it discards the transaction's writes, parks on the read set and re-runs the block. It cannot escape atomically, because retry outside a transaction croaks before this is ever constructed. It is listed here only so that kind has a complete set of values.
Recipes
Retry or give up, by kind
sub fetch_with_deadline {
my ( $self, $seconds ) = @_;
my $val = eval { with_timeout( $seconds * 1000, sub { $self->fetch } ) };
my $err = $@;
return $val unless ref $err; # it worked
return unless $err->isa('Acme::Parataxis::Error'); # something else went wrong
return $self->cached_response if $err->kind eq 'cancelled'; # a shutdown, not a failure
$self->log( "deadline: $err" );
return;
}
isa first, then kind. Anything that is not an Acme::Parataxis::Error is a real exception and belongs back on the floor.
Report a deadline, and where it was waiting
$SIG{__DIE__} = sub {
my $err = shift;
return unless ref $err and $err->isa('Acme::Parataxis::Error');
my ( $what, $file, $line ) = @{ $err->wait_reason };
warn sprintf "%s interrupted in %s at %s:%d\n", $err->kind, $what, $file, $line;
};
message is the human-readable line; wait_reason is the same information split apart, which is what you want when the text is going into a log or a metric rather than onto a screen.
Guard this handler against repeats. __DIE__ fires once per die, not once per exception, and an interrupted wait is thrown four times on its way out of with_timeout - see "__DIE__ fires once per die, not once per exception".
Unwrap a nursery failure
my $err = do {
eval {
nursery( sub ($n) {
$n->spawn( sub { fetch( 'a' ) } );
$n->spawn( sub { fetch( 'b' ) } );
} );
};
$@;
};
return unless ref $err and $err->isa('Acme::Parataxis::Error::Nursery');
my $primary = $err->primary;
warn 'primary: ' . ( ref $primary ? $primary->message : $primary ); # the one worth reading
warn 'all: ' . join '; ', map { ref $_ ? $_->message : $_ } $err->failures;
primary is the failure to look at; failures is the rest for when one is not enough. A child may have died with a plain string rather than an object, so guard with ref before calling a method on either list.
Catch a cancellation you can distinguish from a timeout
my $err = do { eval { with_timeout( 5_000, $token, sub { work() } ) }; $@ };
if ( ref $err and $err->kind eq 'timeout' ) { ... } # a bound expired
if ( ref $err and $err->kind eq 'cancelled' ) { ... } # somebody cancelled on purpose
For a wait with no deadline of its own, register and unregister around the wait and cancel from elsewhere.
Do not use isa to tell these two apart. Error::Timeout is not a subclass of Error::Cancelled; both are direct children of the base class.
Behavior Notes
__DIE__ fires once per die, not once per exception
$SIG{__DIE__} is the obvious way to report every diagnostic without wrapping each call site, and for most code it behaves the way you expect. Parataxis does not: an exception is re-thrown at every scope boundary it climbs out of, and each of those throws fires the hook again. The count for one visible exception, caught by the caller:
a token cancelled from another fiber 1
with_cancel, cancelled while the block is parked 2
with_timeout( 20, sub { await_sleep 5_000 } ) 4
a nursery whose child dies with a plain string 5
with_timeout, two levels deep 7
with_timeout( 20, sub { 'returns at once' } ) 0
with_timeout( 0, sub { await_sleep 5_000 } ) 0
Four dies carry a single interrupted wait out of one with_timeout: the park re-throws it when the wait is interrupted, the child fiber that ran the block re-throws it after tearing the deadline down, the dead fiber's await re-throws the error it stored, and with_timeout re-throws the lot into the calling fiber. So the count is followable rather than arbitrary. Each extra with_timeout level adds three more (4, then 7, then 10), each extra fiber you reap with >await adds one (4, 5, 6), and an error you let escape the top-level run adds two (4 becomes 6). A deadline that never parks reports nothing at all, because no exception is ever thrown.
The messages are the part that will not follow a rule. One level deep every fire carries the same message, and deduping collapses them. Nested, a second message turns up naming fiber await inside Parataxis itself, and a nursery adds a nursery failure: summary beside the error of the child that caused it. A handler also sees exceptions that never propagate at all: when a nursery child dies with a plain string, the framework calls a method on it inside a defensive eval, and that trapped failure fires the hook as well.
Can't locate object method "kind" via package "child blew up\n" ... at Error.pm line 53
So report a failure where you catch it, as the recipes above do. Treat a global reporter as a fallback, and build it to expect the noise - dedupe on the message, and filter on isa to drop the trapped internal failures, which are plain strings:
my %seen;
$SIG{__DIE__} = sub {
my $err = shift;
return unless ref $err and $err->isa('Acme::Parataxis::Error');
my $key = "$err"; # the object stringifies to its message
return if $seen{$key}++;
report( $err->kind, $err->wait_reason );
};
That gives one report for a single with_timeout and two for a nested one, the second naming a framework line rather than one of yours.
The seconds accessor is never populated by the framework
Error::Timeout->seconds exists and works, and on any error Parataxis throws itself it returns undef. No call site in the shipped code passes a seconds argument - all five constructions pass only wait_reason - so the after Ns half of the message is never rendered either. A real deadline reads:
The operation timed out at await_sleep (t/job.pl line 42)
not The operation timed out after 0.25s at .... The duration is available from the with_timeout call you wrote.
The accessor is worse than absent if you reach for it, because of units. with_timeout takes milliseconds and croaks otherwise, but the label renders the stored number with a literal s attached:
Acme::Parataxis::Error::Timeout->new( seconds => 250 )->message
# "The operation timed out after 250s at an unknown wait"
so a seconds of 0.25 is the only value that reads correctly, and nothing in the framework ever divides to produce it. If you build the error by hand, convert to seconds yourself.
Nursery and Supervisor disagree about which failure is primary
Both errors take a failures list and both expose primary, and they resolve it by different rules:
my ( $cancelled, $real ) = ( $some_cancelled_error, $some_real_error );
Acme::Parataxis::Error::Nursery->new( failures => [ $cancelled, $real ] )->primary->kind
# 'timeout' - the nursery skips cancellations
Acme::Parataxis::Error::Supervisor->new( failures => [ $cancelled, $real ] )->primary->kind
# 'cancelled' - the supervisor takes the first defined entry
The nursery rule matches its documented intent: the point is to name the real culprit, not the siblings it cancelled. The supervisor rule is simply the first defined entry, so a cancellation listed first is reported as the cause of the budget being exhausted. If you are reading primary off a supervisor error, do not assume it is the interesting one - scan failures for a non-kind eq 'cancelled' entry yourself.
The overload is a string, not a number
The classes overload "" onto message with fallback 1, so comparison operators autogenerate from the string and $err eq $err->message is true. $err->can('eq') is still false, because there is no such method - it is a synthetic operator.
Numeric context is where that bites. $err == $other compares the two message strings and warns Argument "..." isn't numeric in numeric eq on both sides, and 0 + $err warns and yields 0. The scheduler in lib/Acme/Parataxis.c relies on the stringification, not on any numeric form, so this only bites code that compares errors directly.
SEE ALSO
Acme::Parataxis, Acme::Parataxis::CancellationToken, Acme::Parataxis::Nursery, Acme::Parataxis::Supervisor
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.