Directory-Transactional
view release on metacpan or search on metacpan
lib/Directory/Transactional.pm view on Meta::CPAN
my $dir = File::Spec->catdir($b, $name);
if ( my $ex_lock = $self->_get_lock( $dir . ".lock", LOCK_EX|LOCK_NB ) ) {
# there is a potential race condition between the readdir
# and getting the lock. make sure it still exists
if ( -d $dir ) {
$self->online_recover;
return $ex_lock;
}
}
}
# the check passed, now we can clear the dirty flag if there are no
# other running commits
if ( my $flag_ex_lock = $self->_get_lock( $dirty, LOCK_EX|LOCK_NB ) ) {
unlink $dirty;
}
}
# return the lock.
# for as long as it is held the workdir cannot be marked dirty except by
# this process
return $ex_lock;
}
sub set_dirty {
my $self = shift;
return unless $self->crash_detection;
# first check that the dir is not dirty, and take an exclusive lock for
# dirty flag manipulation
my $ex_lock = $self->check_dirty;
# next mark the dir as dirty, and take a shared lock so the flag won't be
# cleared by check_dirty
my $dirty_lock = $self->_get_lock( $self->_dirty, LOCK_SH );
# create the file if necessary (nfs uses an auxillary lock file)
open my $fh, ">", $self->_dirty or die $! if $self->nfs;
return $dirty_lock;
}
sub recover {
my $self = shift;
# first rollback partially comitted transactions if there are any
if ( -d ( my $b = $self->_backups ) ) {
foreach my $name ( IO::Dir->new($b)->read ) {
next if $name eq '.' || $name eq '..';
my $txn_backup = File::Spec->catdir($b, $name); # each of these is one transaction
if ( -d $txn_backup ) {
my $files = $self->_get_file_list($txn_backup);
# move all the backups back into the root directory
$self->merge_overlay( from => $txn_backup, to => $self->_root, files => $files );
remove_tree($txn_backup);
}
}
remove_tree($b, { keep_root => 1 });
}
# delete all temp files (fully comitted but not cleaned up transactions,
# and uncomitted transactions)
if ( -d $self->_txns ) {
remove_tree( $self->_txns, { keep_root => 1 } );
}
unlink $self->_dirty;
unlink $self->_dirty_lock;
}
sub online_recover {
my $self = shift;
unless ( $self->nfs ) { # can't upgrade an nfs lock
my $lock = $self->_shared_lock;
if ( $lock->upgrade_nb ) {
$self->recover;
$lock->downgrade;
return 1;
}
}
$self->_fatal(1);
croak "Detected crashed transaction, terminate all processes and run recovery by reinstantiating directory";
}
sub _get_file_list {
my ( $self, $from ) = @_;
my $files = Set::Object->new;
find( { no_chdir => 1, wanted => sub { $files->insert( File::Spec->abs2rel($_, $from) ) if -f $_ } }, $from );
return $files;
}
sub merge_overlay {
my ( $self, %args ) = @_;
my ( $from, $to, $backup, $files ) = @args{qw(from to backup files)};
my @rem;
# if requested, back up first by moving all the files from the target
# directory to the backup directory
if ( $backup ) {
foreach my $file ( $files->members ) {
my $src = File::Spec->catfile($to, $file);
next unless -e $src; # there is no source file to back
my $targ = File::Spec->catfile($backup, $file);
# create the parent directory in the backup dir as necessary
my ( undef, $dir ) = File::Spec->splitpath($targ);
if ( $dir ) {
make_path($dir) unless -d $dir;
}
CORE::rename $src, $targ or die $!;
}
}
# then apply all the changes to the target dir from the source dir
foreach my $file ( $files->members ) {
my $src = File::Spec->catfile($from,$file);
if ( -f $src ) {
my $targ = File::Spec->catfile($to,$file);
# make sure the parent directory in the target path exists first
my ( undef, $dir ) = File::Spec->splitpath($targ);
if ( $dir ) {
make_path($dir) unless -d $dir;
}
if ( -f $src ) {
CORE::rename $src => $targ or die $!;
} elsif ( -f $targ ) {
CORE::unlink $targ or die $!;
}
}
}
}
sub txn_do {
my ( $self, @args ) = @_;
unshift @args, "body" if @args % 2;
my %args = @args;
my ( $coderef, $commit, $rollback, $code_args ) = @args{qw(body commit rollback args)};
ref $coderef eq 'CODE' or croak '$coderef must be a CODE reference';
$code_args ||= [];
lib/Directory/Transactional.pm view on Meta::CPAN
parent => $p,
manager => $self,
);
} else {
# this is a top level transaction
$txn = Directory::Transactional::TXN::Root->new(
@args,
manager => $self,
( $self->global_lock ? (
# when global_lock is set, take an exclusive lock on the root dir
# non global lockers take a shared lock on it
global_lock => $self->_get_flock( File::Spec->catfile( $self->_locks, ".lock" ), LOCK_EX)
) : () ),
);
}
$self->_txn($txn);
return;
}
sub _pop_txn {
my $self = shift;
my $txn = $self->_txn or croak "No active transaction";
if ( $txn->isa("Directory::Transactional::TXN::Nested") ) {
$self->_txn( $txn->parent );
} else {
$self->_clear_txn;
}
return $txn;
}
sub txn_commit {
my $self = shift;
my $txn = $self->_txn;
my $changed = $txn->changed;
if ( $changed->size ) {
if ( $txn->isa("Directory::Transactional::TXN::Root") ) {
# commit the work, backing up in the backup dir
# first take a lock on the backup dir
# this is used to detect crashed transactions
# if the dir exists but isn't locked then the transaction crashed
my $txn_lockfile = $txn->backup . ".lock";
my $txn_lock = $self->_get_lock( $txn_lockfile, LOCK_EX );
{
# during a commit the work dir is considered dirty
# this flag is set until check_dirty clears it
my $dirty_lock = $self->set_dirty;
$txn->create_backup_dir;
# move all the files from the txn dir into the root dir, using the backup dir
$self->merge_overlay( from => $txn->work, to => $self->_root, backup => $txn->backup, files => $changed );
# we're finished, remove backup dir denoting successful commit
CORE::rename $txn->backup, $txn->work . ".cleanup" or die $!;
}
unlink $txn_lockfile;
} else {
# it's a nested transaction, which means we don't need to be
# careful about comitting to the parent, just share all the locks,
# deletion metadata etc by merging it
$txn->propagate;
$self->merge_overlay( from => $txn->work, to => $txn->parent->work, files => $changed );
}
# clean up work dir and (renamed) backup dir
remove_tree( $txn->work );
remove_tree( $txn->work . ".cleanup" );
}
$self->_pop_txn;
return;
}
sub txn_rollback {
my $self = shift;
my $txn = $self->_pop_txn;
if ( $txn->isa("Directory::Transactional::TXN::Root") ) {
# an error happenned during txn_commit trigerring a rollback
if ( -d ( my $txn_backup = $txn->backup ) ) {
my $files = $self->_get_file_list($txn_backup);
# move all the backups back into the root directory
$self->merge_overlay( from => $txn_backup, to => $self->_root, files => $files );
}
} else {
# any inherited locks that have been upgraded in this txn need to be
# downgraded back to shared locks
foreach my $lock ( @{ $txn->downgrade } ) {
$lock->downgrade;
}
}
# now all we need to do is trash the tempfiles and we're done
if ( $txn->has_work ) {
remove_tree( $txn->work );
}
return;
}
sub _auto_txn {
my $self = shift;
return if $self->_txn;
croak "Auto commit is disabled" unless $self->auto_commit;
require Scope::Guard;
$self->txn_begin;
return Scope::Guard->new(sub { $self->txn_commit });
}
sub _resource_auto_txn {
my $self = shift;
if ( my $txn = $self->_txn ) {
# return the same handle so that more resources can be registered
return $txn->auto_handle;
} else {
croak "Auto commit is disabled" unless $self->auto_commit;
require Directory::Transactional::AutoCommit;
my $h = Directory::Transactional::AutoCommit->new( manager => $self );
$self->txn_begin( auto_handle => $h );
return $h;
}
}
sub _lock_path_read {
my ( $self, $path ) = @_;
my $txn = $self->_txn;
if ( my $lock = $txn->find_lock($path) ) {
return $lock;
} else {
my $lock = $self->_get_flock( File::Spec->catfile( $self->_locks, $path . ".lock" ), LOCK_SH);
$txn->set_lock( $path, $lock );
lib/Directory/Transactional.pm view on Meta::CPAN
# lock a path for reading
sub lock_path_read {
my ( $self, $path ) = @_;
unless ( $self->_txn ) {
croak("Can't lock file for reading without an active transaction");
}
return if $self->global_lock;
$self->_lock_parent($path);
$self->_lock_path_read($path);
return;
}
sub lock_path_write {
my ( $self, $path ) = @_;
unless ( $self->_txn ) {
croak("Can't lock file for writing without an active transaction");
}
return if $self->global_lock;
$self->_lock_parent($path);
$self->_lock_path_write($path);
return;
}
sub _txn_stack {
my $self = shift;
if ( my $txn = $self->_txn ) {
my @ret = $txn;
push @ret, $txn = $txn->parent while $txn->can("parent");
return @ret;
}
return;
}
sub _txn_for_path {
my ( $self, $path ) = @_;
if ( my $txn = $self->_txn ) {
do {
if ( $txn->is_changed_in_head($path) ) {
return $txn;
};
} while ( $txn->can("parent") and $txn = $txn->parent );
}
return;
}
sub _locate_dirs_in_overlays {
my ( $self, $path ) = @_;
my @dirs = ( (map { $_->work } $self->_txn_stack), $self->root );
if ( defined $path ) {
return grep { -d $_ } map { File::Spec->catdir($_, $path) } @dirs;
} else {
return @dirs;
}
}
sub _locate_file_in_overlays {
my ( $self, $path ) = @_;
if ( my $txn = $self->_txn_for_path($path) ) {
File::Spec->catfile($txn->work, $path);
} else {
#unless ( $self->_txn->find_lock($path) ) { # can't optimize this way if an explicit lock was taken
# we only take a read lock on the root dir if the state isn't dirty
my $ex_lock = $self->check_dirty;
$self->lock_path_read($path);
#}
File::Spec->catfile($self->_root, $path);
}
}
sub old_stat {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
CORE::stat($self->_locate_file_in_overlays($path));
}
sub stat {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
require File::stat;
File::stat::stat($self->_locate_file_in_overlays($path));
}
sub is_deleted {
my ( $self, $path ) = @_;
not $self->exists($path);
}
sub exists {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
return -e $self->_locate_file_in_overlays($path);
}
sub is_dir {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
# FIXME this is an ugly kludge, we really need to keep better track of
# why/when directories are created, make note of them in 'is_changed', etc.
my @dirs = ( (map { $_->work } $self->_txn_stack), $self->root );
foreach my $dir ( @dirs ) {
return 1 if -d File::Spec->catdir($dir, $path);
}
return;
}
sub is_file {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
return -f $self->_locate_file_in_overlays($path);
}
sub unlink {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
# lock parent for writing
my ( undef, $dir ) = File::Spec->splitpath($path);
$self->lock_path_write($dir);
my $txn_file = $self->_work_path($path);
if ( -e $txn_file ) {
CORE::unlink $txn_file or die $!;
} else {
return 1;
}
}
sub rename {
my ( $self, $from, $to ) = @_;
my $t = $self->_auto_txn;
foreach my $path ( $from, $to ) {
# lock parents for writing
my ( undef, $dir ) = File::Spec->splitpath($path);
$self->lock_path_write($dir);
}
$self->vivify_path($from),
CORE::rename (
$self->_work_path($from),
$self->_work_path($to),
) or die $!;
}
sub openr {
my ( $self, $file ) = @_;
my $t = $self->_resource_auto_txn;
my $src = $self->_locate_file_in_overlays($file);
open my $fh, "<", $src or die "openr($file): $!";
$t->register($fh) if $t;
return $fh;
}
sub openw {
my ( $self, $path ) = @_;
my $t = $self->_resource_auto_txn;
my $txn = $self->_txn;
my $file = File::Spec->catfile( $txn->work, $path );
unless ( $txn->is_changed_in_head($path) ) {
my ( undef, $dir ) = File::Spec->splitpath($path);
$self->lock_path_write($path);
make_path( File::Spec->catdir($txn->work, $dir) ) if length($dir); # FIXME only if it exists in the original?
}
$txn->mark_changed($path);
open my $fh, ">", $file or die "openw($path): $!";
$t->register($fh) if $t;
return $fh;
}
sub opena {
my ( $self, $file ) = @_;
my $t = $self->_resource_auto_txn;
$self->vivify_path($file);
open my $fh, ">>", $self->_work_path($file) or die "opena($file): $!";
$t->register($fh) if $t;
return $fh;
}
sub open {
my ( $self, $mode, $file ) = @_;
my $t = $self->_resource_auto_txn;
$self->vivify_path($file);
open my $fh, $mode, $self->_work_path($file) or die "open($mode, $file): $!";
$t->register($fh) if $t;
return $fh;
}
sub _readdir_from_overlay {
my ( $self, $path ) = @_;
my $t = $self->_auto_txn;
my $ex_lock = $self->check_dirty;
my @dirs = $self->_locate_dirs_in_overlays($path);
my $files = Set::Object->new;
# compute union of all directories
foreach my $dir ( @dirs ) {
$files->insert( IO::Dir->new($dir)->read );
}
unless ( defined $path ) {
$files->remove(".txn_work_dir");
$files->remove(".txn_work_dir.lock");
$files->remove(".txn_work_dir.lock.NFSLock") if $self->nfs;
}
return $files;
}
sub readdir {
my ( $self, $path ) = @_;
undef $path if $path eq "/" or !length($path);
my $t = $self->_auto_txn;
my $files = $self->_readdir_from_overlay($path);
my @txns = $self->_txn_stack;
# remove deleted files
file: foreach my $file ( $files->members ) {
next if $file eq '.' or $file eq '..';
my $file_path = $path ? File::Spec->catfile($path, $file) : $file;
foreach my $txn ( @txns ) {
if ( $txn->is_changed_in_head($file_path) ) {
if ( not( -e File::Spec->catfile( $txn->work, $file_path ) ) ) {
$files->remove($file);
}
next file;
}
}
}
return $files->members;
}
sub list {
my ( $self, $path ) = @_;
undef $path if $path eq "/" or !length($path);
my $t = $self->_auto_txn;
my $files = $self->_readdir_from_overlay($path);
$files->remove('.', '..');
my @txns = $self->_txn_stack;
my @ret;
# remove deleted files
file: foreach my $file ( $files->members ) {
my $file_path = $path ? File::Spec->catfile($path, $file) : $file;
foreach my $txn ( @txns ) {
if ( $txn->is_changed_in_head($file_path) ) {
if ( -e File::Spec->catfile( $txn->work, $file_path ) ) {
push @ret, $file_path;
}
next file;
}
}
push @ret, $file_path;
}
return sort @ret;
}
sub _work_path {
my ( $self, $path ) = @_;
$self->lock_path_write($path);
my $txn = $self->_txn;
$txn->mark_changed($path);
my $file = File::Spec->catfile( $txn->work, $path );
my ( undef, $dir ) = File::Spec->splitpath($path);
make_path( File::Spec->catdir($txn->work, $dir ) ) if length($dir); # FIXME only if it exists in the original?
return $file;
}
sub vivify_path {
my ( $self, $path ) = @_;
my $txn = $self->_txn;
my $txn_path = File::Spec->catfile( $txn->work, $path );
unless ( $txn->is_changed_in_head($path) ) {
$self->lock_path_write($path);
my $src = $self->_locate_file_in_overlays($path);
if ( my $stat = File::stat::stat($src) ) {
if ( $stat->nlink > 1 ) {
croak "the file $src has a link count of more than one.";
}
if ( -l $src ) {
croak "The file $src is a symbolic link.";
}
$self->_work_path($path); # FIXME vivifies parent dir
copy( $src, $txn_path ) or die "copy($src, $txn_path): $!";
}
}
return $txn_path;
}
sub file_stream {
my ( $self, @args ) = @_;
my $t = $self->_resource_auto_txn;
require Directory::Transactional::Stream;
my $stream = Directory::Transactional::Stream->new(
manager => $self,
@args,
);
$t->register($stream) if $t;
return $stream;
}
__PACKAGE__->meta->make_immutable;
__PACKAGE__
__END__
=pod
=head1 NAME
=head1 VERSION
version 0.09
Directory::Transactional - ACID transactions on a set of files with
journalling/recovery using C<flock> or L<File::NFSLock>
=head1 SYNOPSIS
use Directory::Transactional;
my $d = Directory::Transactional->new( root => $path );
lib/Directory/Transactional.pm view on Meta::CPAN
If you do, then make sure even your read access goes through this object with
an active transaction, or you may risk reading uncomitted data, or conflicting
with the transaction commit code.
=item Use C<global_lock> or make sure you lock right
If you stick to modifying the files through the API then you shouldn't have
issues with locking, but try not to reuse paths and always reask for them to
ensure that the right "real" path is returned even if the transaction stack has
changed, or anything else.
=item No forking
If you fork in the middle of the transaction both the parent and the child have
write locks, and both the parent and the child will try to commit or rollback
when resources are being cleaned up.
Either create the L<Directory::Transactional> instance within the child
process, or use L<POSIX/_exit> and do not open or close any transactions in the
child.
=item No mixing of C<nfs> and C<flock>
C<nfs> mode is not compatible with C<flock> mode. If you enable C<nfs> enable
it in B<all> processes working on the same directory.
Conversely, under C<flock> mode C<global_lock> B<is> compatible with fine
grained locking.
=back
=head1 ACID GUARANTEES
ACID stands for atomicity, consistency, isolation and durability.
Transactions are atomic (using locks), consistent (a recovery mode is able to
restore the state of the directory if a process crashed while comitting a
transaction), isolated (each transaction works in its own temporary directory),
and durable (once C<txn_commit> returns a software crash will not cause the
transaction to rollback).
=head1 TRANSACTIONAL PROTOCOL
This section describes the way the ACID guarantees are met:
When the object is being constructed a nonblocking attempt to get an exclusive
lock on the global shared lock file using L<File::NFSLock> or C<flock> is made.
If this lock is successful this means that this object is the only active
instance, and no other instance can access the directory for now.
The work directory's state is inspected, any partially comitted transactions
are rolled back, and all work files are cleaned up, producing a consistent
state.
At this point the exclusive lock is dropped, and a shared lock on the same file
is taken, which will be retained for the lifetime of the object.
Each transaction (root or nested) gets its own work directory, which is an
overlay of its parent.
All write operations are performed in the work directory, while read operations
walk up the tree.
Aborting a transaction consists of simply removing its work directory.
Comitting a nested transaction involves overwriting its parent's work directory
with all the changes in the child transaction's work directory.
Comitting a root transaction to the root directory involves moving aside every
file from the root to a backup directory, then applying the changes in the work
directory to the root, renaming the backup directory to a work directory, and
then cleaning up the work directory and the renamed backup directory.
If at any point in the root transaction commit work is interrupted, the backup
directory acts like a journal entry. Recovery will rollback this transaction by
restoring all the renamed backup files. Moving the backup directory into the
work directory signifies that the transaction has comitted successfully, and
recovery will clean these files up normally.
If C<crash_detection> is enabled (the default) when reading any file from the
root directory (shared global state) the system will first check for crashed
commits.
Crashed commits are detected by means of lock files. If the backup directory is
locked that means its comitting process is still alive, but if a directory
exists without a lock then that process has crashed. A global dirty flag is
maintained to avoid needing to check all the backup directories each time.
If the commit is still running then it can be assumed that the process
comitting it still has all of its exclusive locks so reading from the root
directory is safe.
=head1 DEADLOCKS
This module does not implement deadlock detection. Unfortunately maintaing a
lock table is a delicate and difficult task, so I doubt I will ever implement
it.
The good news is that certain operating systems (like HPUX) may implement
deadlock detection in the kernel, and return C<EDEADLK> instead of just
blocking forever.
If you are not so lucky, specify a C<timeout> or make sure you always take
locks in the same order.
The C<global_lock> flag can also be used to prevent deadlocks entirely, at the
cost of concurrency. This provides fully serializable level transaction
isolation with no possibility of serialization failures due to deadlocks.
There is no pessimistic locking mode (read-modify-write optimized) since all
paths leading to a file are locked for reading. This mode, if implemented,
would be semantically identical to C<global_lock> but far less efficient.
In the future C<fcntl> based locking may be implemented in addition to
C<flock>. C<EDEADLK> seems to be more widely supported when using C<fcntl>.
=head1 LIMITATIONS
=head2 Auto-Commit
lib/Directory/Transactional.pm view on Meta::CPAN
Note that these methods are no-ops if C<global_lock> is set.
=back
=head2 File Access
=over 4
=item openr $path
=item openw $path
=item opena $path
=item open $mode, $path
Open a file for reading, writing (clobbers) or appending, or with a custom mode
for three arg open.
Using C<openw> or C<openr> is reccomended if that's all you need, because it
will not copy the file into the transaction work dir first.
=item stat $path
Runs L<File::stat/stat> on the physical path.
=item old_stat $path
Runs C<CORE::stat> on the physical path.
=item exists $path
=item is_deleted $path
Whether a file exists or has been deleted in the current transaction.
=item is_file $path
Runs the C<-f> file test on the right physical path.
=item is_dir $path
Runs the C<-d> file test on the right physical path.
=item unlink $path
Deletes the file in the current transaction
=item rename $from, $to
Renames the file in the current transaction.
Note that while this is a real C<rename> call in the txn work dir that is done
on a copy, when comitting to the top level directory the original will be
unlinked and the new file from the txn work dir will be renamed to the original.
Hard links will B<NOT> be retained.
=item readdir $path
Merges the overlays of all the transactions and returns unsorted basenames.
A path of C<""> can be used to list the root directory.
=item list $path
A DWIM version of C<readdir> that returns paths relative to C<root>, filters
out C<.> and C<..> and sorts the output.
A path of C<""> can be used to list the root directory.
=item file_stream %args
Creates a L<Directory::Transactional::Stream> for a recursive file listing.
The C<dir> option can be used to specify a directory, defaulting to C<root>.
=back
=head2 Internal Methods
These are documented so that they may provide insight into the inner workings
of the module, but should not be considered part of the API.
=over 4
=item merge_overlay
Merges one directory over another.
=item recover
Runs the directory state recovery code.
See L</"TRANSACTIONAL PROTOCOL">
=item online_recover
Called to recover when the directory is already instantiated, by C<check_dirty>
if a dirty state was found.
=item check_dirty
Check for transactions that crashed in mid commit
=item set_dirty
Called just before starting a commit.
=item vivify_path $path
Copies C<$path> as necessary from a parent transaction or the root directory in
order to facilitate local work.
Does not support hard or symbolic links (yet).
=back
=cut
( run in 0.614 second using v1.01-cache-2.11-cpan-c966e8aa7e8 )