App-karr

 view release on metacpan or  search on metacpan

t/155-restore-atomic.t  view on Meta::CPAN

use Path::Tiny qw( path );
use Try::Tiny;

use App::karr::Encoding qw( yaml_dump yaml_load );
use App::karr::Git;

# Ticket #155: `karr restore` (replace_board_refs) validates every ref and
# builds every commit in phase one, then writes the refs in place in phase
# two. Before the fix, a ref write that failed in phase two (a lock
# contention exhaustion that survived retry_contended, a real libgit2 error,
# anything that produced a die out of _write_ref_oid) left the board with
# the first N-1 snapshot refs and the last one still pointing at its
# pre-restore content -- the catastrophic half-apply the test below
# reproduces. The fix snapshots the original refs/karr/* state before phase
# two and restores it on any failure, so the board ends up exactly as it
# was before the failed restore rather than as a mixture.

sub _git_ok {
    my (@cmd) = @_;
    my $rc = system(@cmd);
    is( $rc, 0, "@cmd" );
}

# In-process runner (t/lib/TestKarr.pm): same ($cwd, @argv) signature and
# { exit, stdout, stderr } return as the open3 helper this file used to carry,
# dispatched through the shared App::karr::Dispatch path. KARR_TEST_SUBPROC=1
# restores the old open3 path.
sub _run_karr { return run_karr(@_) }

sub _refs {
    my (@cmd) = @_;
    open my $fh, '-|', @cmd or die "cannot run @cmd: $!";
    my @refs = <$fh>;
    close $fh;
    chomp @refs;
    return sort @refs;
}

sub _local_karr_refs {
    my ($work) = @_;
    return _refs( 'git', '-C', $work, 'for-each-ref', '--format=%(refname)', 'refs/karr/' );
}

sub _board {
    my $root = tempdir( CLEANUP => 1 );
    my $work = "$root/work";

    _git_ok( 'git', 'init', '-q', $work );
    _git_ok( 'git', '-C', $work, 'config', 'user.email', 'test@example.com' );
    _git_ok( 'git', '-C', $work, 'config', 'user.name',  'Test User' );

    is( _run_karr( $work, 'init', '--name', 'Atomic Board' )->{exit}, 0, 'board initialized' );
    is( _run_karr( $work, 'create', "task $_" )->{exit}, 0, "task $_ created" ) for 1 .. 3;

    return ( $root, $work );
}

sub _snapshot_of {
    my ( $work, $root ) = @_;
    my $file = "$root/good.yml";
    is( _run_karr( $work, 'backup', '--output', $file )->{exit}, 0, 'backup written' );
    return ( $file, yaml_load( path($file)->slurp_utf8 ) );
}

subtest 'a write failure in phase two leaves the board untouched' => sub {
    my ( $root, $work ) = _board();
    my ( $file, $snapshot ) = _snapshot_of( $work, $root );

    # Mutate the snapshot so its contents differ from the live board on
    # EVERY ref. Without this change a "the refs read back the same"
    # assertion would still pass on the pre-fix code, because the snapshot
    # and the live board would be identical and the half-applied state
    # would be indistinguishable from the original state. The phase-two
    # write order is sorted, so the very first ref to land is
    # refs/karr/config -- the snapshot's config has a different `name`,
    # so the half-applied state would leave the live board with the
    # snapshot's config and the live board's tasks, which is the bug.
    $snapshot->{refs}{'refs/karr/config'} =~ s/name: \K.*/From Snapshot/m;
    for my $id ( 1 .. 3 ) {
        my $key = "refs/karr/tasks/$id/data";
        my $fm  = $snapshot->{refs}{$key};
        $fm =~ s/^title: .*$/title: "snapshot-of-task-$id"/m;
        $snapshot->{refs}{$key} = $fm;
    }
    my $mutated = "$root/mutated.yml";
    path($mutated)->spew_utf8( yaml_dump($snapshot) );

    my @before = _local_karr_refs($work);
    ok( scalar @before, 'the board has refs before the restore' );

    # Snapshot the content of every board ref so the test can compare
    # contents, not just ref names. The ref-set is the same before and
    # after a half-applied phase two; the bug is in what each ref points
    # at, which is exactly what a "compare the names" assertion misses.
    my $git = App::karr::Git->new( dir => $work );
    my %before_content = map { $_ => $git->read_ref($_) } sort keys %{ $snapshot->{refs} };

    # Phase two writes the refs in sorted order. Inject a failure on the
    # second write so ref 1 lands at the snapshot value, ref 2 fails, and
    # everything past it stays at the pre-restore value. The pre-fix
    # `replace_board_refs` returned 1 because the die happened after some
    # writes had already landed, so the half-applied state was the success
    # path, not the error path.
    my $fail_at = 2;
    my $calls   = 0;
    no warnings 'redefine';
    local *App::karr::Git::_write_ref_oid = sub {
        my ( $self, $ref, $oid ) = @_;
        $calls++;
        die "karr: could not write $ref: injected failure for ticket #155\n"
            if $calls == $fail_at;
        return $self->retry_contended( "ref $ref", sub {
            my $repo = $self->_repo;
            $repo->reference_create( $ref, $oid, force => 1 );
            $App::karr::Git::WRITES++;
            1;
        } );
    };

    my $err = try {
        $git->replace_board_refs( $snapshot->{refs} );



( run in 0.932 second using v1.01-cache-2.11-cpan-4ef0a570458 )