Selkie.git | t/ | 79-store-dispatch-thread-safety.rakutest


use Test;
use lib 'lib';

use Selkie::Store;

# dispatch() is documented as safe to call from any thread: worker
# threads (async fx callbacks, app-owned `start {}` pipelines) push
# events concurrently with the main loop's tick/drain. Before the
# queue was lock-protected, the drain's copy-then-clear could wipe a
# push that landed between the two steps — events were silently lost
# under load (a lost 'image-gen/finished' strands the job UI). These
# tests hammer the queue from many threads and assert nothing is
# dropped, duplicated, or reordered within a thread.
#
# Handlers always run on the main thread (inside $s.tick), so the
# counters below are plain variables — no atomics needed.

plan 4;

subtest "concurrent dispatch from many threads loses no events" => {
    plan 2;
    my $s = Selkie::Store.new;

    my Int $handled = 0;
    $s.register-handler('bump', -> $st, %ev {
        $handled++;
        ();
    });

    my constant THREADS    = 8;
    my constant PER-THREAD = 500;
    my constant TOTAL      = THREADS * PER-THREAD;

    # Tick on the main thread while the workers are still pushing —
    # that interleaving is exactly the race the lock has to win. A
    # barrier-then-drain test would never catch the lost-update bug.
    my @workers = (^THREADS).map: -> $t {
        start {
            for ^PER-THREAD -> $i {
                $s.dispatch('bump', thread => $t, seq => $i);
            }
        }
    };
    my $all-pushed = Promise.allof(@workers);

    my $deadline = now + 60;
    while now < $deadline {
        $s.tick;
        last if $all-pushed.status ~~ Kept && $handled == TOTAL;
    }
    await $all-pushed;
    # Final drain — anything pushed after the last tick above.
    $s.tick;

    is @workers.grep({ .status ~~ Kept }).elems, THREADS,
        "all dispatching workers completed";
    is $handled, TOTAL,
        "every one of {TOTAL} cross-thread dispatches was handled";
};

subtest "per-thread dispatch order is preserved" => {
    plan 8;
    my $s = Selkie::Store.new;

    my %seen-seq;    # thread → last seq handled
    my %in-order;    # thread → Bool
    $s.register-handler('ordered', -> $st, %ev {
        my $t   = %ev<thread>;
        my $seq = %ev<seq>;
        %in-order{$t} //= True;
        %in-order{$t} = False
            if %seen-seq{$t}:exists && %seen-seq{$t} >= $seq;
        %seen-seq{$t} = $seq;
        ();
    });

    my constant THREADS    = 8;
    my constant PER-THREAD = 200;

    my @workers = (^THREADS).map: -> $t {
        start {
            for ^PER-THREAD -> $i {
                $s.dispatch('ordered', thread => $t, seq => $i);
            }
        }
    };
    my $all-pushed = Promise.allof(@workers);

    my $deadline = now + 60;
    while now < $deadline {
        $s.tick;
        last if $all-pushed.status ~~ Kept
            && %seen-seq.elems == THREADS
            && %seen-seq.values.all == PER-THREAD - 1;
    }
    await $all-pushed;
    $s.tick;

    for ^THREADS -> $t {
        ok %in-order{$t} && %seen-seq{$t} == PER-THREAD - 1,
            "thread $t: all {PER-THREAD} events handled in dispatch order";
    }
};

subtest "handlers may re-dispatch while the drain holds no lock" => {
    plan 1;
    my $s = Selkie::Store.new;

    # A handler that dispatches directly (not via the dispatch fx)
    # exercises push-during-drain from the main thread. A non-reentrant
    # Lock held across handler execution would deadlock here.
    my @order;
    $s.register-handler('first', -> $st, %ev {
        @order.push('first');
        $st.dispatch('second');
        ();
    });
    $s.register-handler('second', -> $st, %ev {
        @order.push('second');
        ();
    });

    $s.dispatch('first');
    $s.tick;
    is @order.join(','), 'first,second',
        "re-dispatched event processed in the same tick, no deadlock";
};

subtest "async fx on-success dispatch from worker thread is delivered" => {
    plan 1;
    my $s = Selkie::Store.new;

    my $got;
    $s.register-handler('work/start', -> $st, %ev {
        (async => {
            work       => -> { 'payload-from-worker' },
            on-success => 'work/done',
        },);
    });
    $s.register-handler('work/done', -> $st, %ev {
        $got = %ev<result>;
        ();
    });

    $s.dispatch('work/start');
    my $deadline = now + 60;
    while now < $deadline {
        $s.tick;
        last if $got.defined;
    }
    is $got, 'payload-from-worker',
        "worker-thread on-success dispatch arrived through the locked queue";
};