package CHI::Driver; $CHI::Driver::VERSION = '0.61'; use Carp; use CHI::CacheObject; use CHI::Constants qw(CHI_Max_Time); use CHI::Driver::Metacache; use CHI::Driver::Role::HasSubcaches; use CHI::Driver::Role::IsSizeAware; use CHI::Driver::Role::IsSubcache; use CHI::Driver::Role::Universal; use CHI::Serializer::Storable; use CHI::Serializer::JSON; use CHI::Util qw(parse_duration); use CHI::Types qw(:all); use Digest::MD5; use Encode; use Hash::MoreUtils qw(slice_grep); use Log::Any qw($log); use Moo; use MooX::Types::MooseLike::Base qw(:all); use Scalar::Util qw(blessed); use Time::Duration; use Time::HiRes qw(gettimeofday); use strict; use warnings; my $default_serializer = CHI::Serializer::Storable->new(); my $default_key_serializer = CHI::Serializer::JSON->new(); my $default_key_digester = Digest::MD5->new(); my @common_params; { my %attr = ( chi_root_class => { is => 'ro', }, compress_threshold => { is => 'ro', isa => Int, }, constructor_params => { is => 'ro', init_arg => undef, }, driver_class => { is => 'ro', }, expires_at => { is => 'rw', default => sub { CHI_Max_Time }, }, expires_in => { is => 'rw', isa => Duration, coerce => \&to_Duration, }, expires_on_backend => { is => 'ro', isa => Num, default => sub { 0 }, }, expires_variance => { is => 'rw', isa => Num, default => sub { 0 }, }, has_subcaches => { is => 'lazy', isa => Bool, init_arg => undef, }, is_size_aware => { is => 'ro', isa => Bool, }, is_subcache => { is => 'ro', isa => Bool, }, key_digester => { is => 'ro', isa => Digester, coerce => \&to_Digester, default => sub { $default_key_digester }, }, key_serializer => { is => 'ro', isa => Serializer, coerce => \&to_Serializer, default => sub { $default_key_serializer }, }, label => { is => 'rw', lazy => 1, builder => 1, clearer => 1, predicate => 1, }, max_build_depth => { is => 'ro', default => sub { 8 }, }, max_key_length => { is => 'ro', isa => Int, default => sub { 1 << 31 }, }, metacache => { is => 'lazy', clearer => 1, predicate => 1, }, namespace => { is => 'ro', isa => Str, default => sub { 'Default' }, }, on_get_error => { is => 'rw', isa => OnError, default => sub { 'log' }, }, on_set_error => { is => 'rw', isa => OnError, default => sub { 'log' }, }, serializer => { is => 'ro', isa => Serializer, coerce => \&to_Serializer, default => sub { $default_serializer }, }, short_driver_name => { is => 'lazy', clearer => 1, predicate => 1, }, storage => { is => 'ro', }, ); push @common_params, keys %attr; for my $attr ( keys %attr ) { has $attr => %{ $attr{$attr} }; } } sub _build_has_subcaches { undef } # These methods must be implemented by subclass foreach my $method (qw(fetch store remove get_keys get_namespaces)) { no strict 'refs'; *{$method} = sub { die "method '$method' must be implemented by subclass" }; } # Given a hash of params, return the subset that are not in CHI's common parameters. # push @common_params, qw( discard_policy discard_timeout l1_cache max_size max_size_reduction_factor mirror_cache parent_cache subcache_type subcaches ); my %common_params = map { ( $_, 1 ) } @common_params; sub non_common_constructor_params { my ( $class, $params ) = @_; return { map { ( $_, $params->{$_} ) } grep { !$common_params{$_} } keys(%$params) }; } sub declare_unsupported_methods { my ( $class, @methods ) = @_; foreach my $method (@methods) { no strict 'refs'; *{"$class\::$method"} = sub { croak "method '$method' not supported by '$class'" }; } } sub cache_object_class { 'CHI::CacheObject' } # To override time() for testing - must be writable in a dynamically scoped way from tests our $Test_Time; ## no critic (ProhibitPackageVars) our $Build_Depth = 0; ## no critic (ProhibitPackageVars) sub valid_get_options { qw(expire_if busy_lock) } sub valid_set_options { qw(expires_at expires_in expires_variance) } sub BUILD { my ( $self, $params ) = @_; # Ward off infinite build recursion, e.g. from circular subcache configuration. # local $Build_Depth = $Build_Depth + 1; die "$Build_Depth levels of CHI cache creation; infinite recursion?" if ( $Build_Depth > $self->max_build_depth ); # Save off constructor params. Used to create metacache, for # example. Hopefully this will not cause circular references... # $self->{constructor_params} = {%$params}; foreach my $param (qw(l1_cache mirror_cache parent_cache)) { delete( $self->{constructor_params}->{$param} ); } # If stats enabled, add ns_stats slot for keeping track of stats # my $stats = $self->chi_root_class->stats; if ( $stats->enabled ) { $self->{ns_stats} = $stats->stats_for_driver($self); } # Call BUILD_roles on any of the roles that need initialization. # $self->BUILD_roles($params); } sub BUILD_roles { # Will be modified by roles that need it } sub _build_short_driver_name { my ($self) = @_; ( my $name = $self->driver_class ) =~ s/^CHI::Driver:://; return $name; } sub _build_label { my ($self) = @_; return $self->short_driver_name; } sub _build_metacache { my $self = shift; return CHI::Driver::Metacache->new( owner_cache => $self ); } sub get { my ( $self, $key, %params ) = @_; croak "must specify key" unless defined($key); my $ns_stats = $self->{ns_stats}; my $log_is_debug = $log->is_debug; my $measure_time = defined($ns_stats) || $log_is_debug; my ( $start_time, $elapsed_time ); # Fetch cache object # $start_time = gettimeofday() if $measure_time; my $obj = eval { $params{obj} || $self->get_object($key) }; $elapsed_time = ( gettimeofday() - $start_time ) * 1000 if $measure_time; if ( my $error = $@ ) { $ns_stats->{'get_errors'}++ if defined($ns_stats); $self->_handle_get_error( $error, $key ); return undef; } if ( !defined $obj ) { $self->_record_get_stats( 'absent_misses', $elapsed_time ) if defined($ns_stats); $self->_log_get_result( $log, "MISS (not in cache)", $key, $elapsed_time ) if $log_is_debug; return undef; } if ( defined( my $obj_ref = $params{obj_ref} ) ) { $$obj_ref = $obj; } # Check if expired # my $is_expired = $obj->is_expired() || ( defined( $params{expire_if} ) && $params{expire_if}->( $obj, $self ) ); if ($is_expired) { $self->_record_get_stats( 'expired_misses', $elapsed_time ) if defined($ns_stats); $self->_log_get_result( $log, "MISS (expired)", $key, $elapsed_time ) if $log_is_debug; # If busy_lock value provided, set a new "temporary" expiration time that many # seconds forward before returning undef # if ( defined( my $busy_lock = $params{busy_lock} ) ) { my $time = $Test_Time || time(); my $busy_lock_time = $time + parse_duration($busy_lock); $obj->set_early_expires_at($busy_lock_time); $obj->set_expires_at($busy_lock_time); $self->set_object( $key, $obj ); } return undef; } $self->_record_get_stats( 'hits', $elapsed_time ) if defined($ns_stats); $self->_log_get_result( $log, "HIT", $key, $elapsed_time ) if $log_is_debug; return $obj->value; } sub _record_get_stats { my ( $self, $stat, $elapsed_time ) = @_; $self->{ns_stats}->{$stat}++; $self->{ns_stats}->{'get_time_ms'} += $elapsed_time; } sub unpack_from_data { my ( $self, $key, $data ) = @_; return $self->cache_object_class->unpack_from_data( $key, $data, $self->serializer ); } sub get_object { my ( $self, $key ) = @_; croak "must specify key" unless defined($key); $key = $self->transform_key($key); my $data = $self->fetch($key) or return undef; my $obj = $self->unpack_from_data( $key, $data ); return $obj; } sub get_expires_at { my ( $self, $key ) = @_; croak "must specify key" unless defined($key); if ( my $obj = $self->get_object($key) ) { return $obj->expires_at; } else { return; } } sub exists_and_is_expired { my ( $self, $key ) = @_; croak "must specify key" unless defined($key); if ( my $obj = $self->get_object($key) ) { return $obj->is_expired; } else { return; } } sub is_valid { my ( $self, $key ) = @_; croak "must specify key" unless defined($key); if ( my $obj = $self->get_object($key) ) { return !$obj->is_expired; } else { return; } } sub _default_set_options { my $self = shift; return { map { $_ => $self->$_() } qw( expires_at expires_in expires_variance ) }; } sub set { my $self = shift; my ( $key, $value, $options ) = @_; croak "must specify key" unless defined($key); $key = $self->transform_key($key); return unless defined($value); # Fill in $options if not passed, copy if passed, and apply defaults. # if ( !defined($options) ) { $options = $self->_default_set_options; } else { if ( !ref($options) ) { if ( $options eq 'never' ) { $options = { expires_at => CHI_Max_Time }; } elsif ( $options eq 'now' ) { $options = { expires_in => 0 }; } else { $options = { expires_in => $options }; } } # Disregard default expires_at and expires_in if either are provided # if ( exists( $options->{expires_at} ) || exists( $options->{expires_in} ) ) { $options = { expires_variance => $self->expires_variance, %$options }; } else { $options = { %{ $self->_default_set_options }, %$options }; } } $self->set_with_options( $key, $value, $options ); } sub set_with_options { my ( $self, $key, $value, $options ) = @_; my $ns_stats = $self->{ns_stats}; my $log_is_debug = $log->is_debug; my $measure_time = defined($ns_stats) || $log_is_debug; my ( $start_time, $elapsed_time ); # Determine early and final expiration times # my $time = $Test_Time || time(); my $created_at = $time; my $expires_at = ( defined( $options->{expires_in} ) ) ? $time + parse_duration( $options->{expires_in} ) : $options->{expires_at}; my $early_expires_at = defined( $options->{early_expires_at} ) ? $options->{early_expires_at} : ( $expires_at == CHI_Max_Time ) ? CHI_Max_Time : $expires_at - ( ( $expires_at - $time ) * $options->{expires_variance} ); # Pack into data, and store # my $obj = $self->cache_object_class->new( $key, $value, $created_at, $early_expires_at, $expires_at, $self->serializer, $self->compress_threshold ); if ( defined( my $obj_ref = $options->{obj_ref} ) ) { $$obj_ref = $obj; } $start_time = gettimeofday() if $measure_time; if ( $self->set_object( $key, $obj ) ) { $elapsed_time = ( gettimeofday() - $start_time ) * 1000 if $measure_time; # Log the set # if ( defined($ns_stats) ) { $ns_stats->{'sets'}++; $ns_stats->{'set_key_size'} += length( $obj->key ); $ns_stats->{'set_value_size'} += $obj->size; $ns_stats->{'set_time_ms'} += $elapsed_time; } if ($log_is_debug) { $self->_log_set_result( $log, $obj, $elapsed_time ); } } return $value; } sub set_object { my ( $self, $key, $obj ) = @_; my $data = $obj->pack_to_data(); my $expires_on_backend = $self->expires_on_backend; my @expires_in = ( $expires_on_backend && $obj->expires_at < CHI_Max_Time ? ( ( $obj->expires_at - time ) * $expires_on_backend ) : () ); eval { $self->store( $key, $data, @expires_in ) }; if ( my $error = $@ ) { $self->{ns_stats}->{'set_errors'}++ if defined( $self->{ns_stats} ); $self->_handle_set_error( $error, $obj ); return 0; } return 1; } sub get_keys_iterator { my ($self) = @_; my @keys = $self->get_keys(); return sub { shift(@keys) }; } sub clear { my $self = shift; die "clear takes no arguments" if @_; $self->remove_multi( [ $self->get_keys() ] ); } sub expire { my ( $self, $key ) = @_; croak "must specify key" unless defined($key); my $time = $Test_Time || time(); if ( defined( my $obj = $self->get_object($key) ) ) { my $expires_at = $time - 1; $obj->set_early_expires_at($expires_at); $obj->set_expires_at($expires_at); $self->set_object( $key, $obj ); } } sub compute { my $self = shift; my $key = shift; my $wantarray = wantarray(); # Allow these in either order for backward compatibility my ( $code, $options ) = ( ref( $_[0] ) eq 'CODE' ) ? ( $_[0], $_[1] ) : ( $_[1], $_[0] ); croak "must specify key and code" unless defined($key) && defined($code); my %get_options = ( ref($options) eq 'HASH' ) ? slice_grep { /(?:expire_if|busy_lock)/ } $options : (); my $set_options = ( ref($options) eq 'HASH' ) ? { slice_grep { !/(?:expire_if|busy_lock)/ } $options } : $options; my $value = $self->get( $key, %get_options ); if ( !defined $value ) { my ( $start_time, $elapsed_time ); my $ns_stats = $self->{ns_stats}; $start_time = gettimeofday if defined($ns_stats); $value = $wantarray ? [ $code->() ] : $code->(); $elapsed_time = ( gettimeofday() - $start_time ) * 1000 if defined($ns_stats); $self->set( $key, $value, $set_options ); if ( defined($ns_stats) ) { $ns_stats->{'computes'}++; $ns_stats->{'compute_time_ms'} += $elapsed_time; } } return $wantarray ? @$value : $value; } sub purge { my ($self) = @_; foreach my $key ( $self->get_keys() ) { if ( my $obj = $self->get_object($key) ) { if ( $obj->is_expired() ) { $self->remove($key); } } } } sub dump_as_hash { my ($self) = @_; my %hash; foreach my $key ( $self->get_keys() ) { if ( defined( my $value = $self->get($key) ) ) { $hash{$key} = $value; } } return \%hash; } sub is_empty { my ($self) = @_; return !$self->get_keys(); } # # (SEMI-) ATOMIC OPERATIONS # sub add { my $self = shift; my $key = shift; if ( !$self->is_valid($key) ) { $self->set( $key, @_ ); } } sub append { my ( $self, $key, $new ) = @_; my $current = $self->fetch($key) or return undef; $self->store( $key, $current . $new ); return 1; } sub replace { my $self = shift; my $key = shift; if ( $self->is_valid($key) ) { $self->set( $key, @_ ); } } # # MULTI KEY OPERATIONS # sub fetch_multi_hashref { my ( $self, $keys ) = @_; return { map { ( $_, $self->fetch($_) ) } @$keys }; } sub get_multi_hashref_objects { my ( $self, $keys ) = @_; my $key_data = $self->fetch_multi_hashref($keys); return { map { my $data = $key_data->{$_}; defined($data) ? ( $_, $self->unpack_from_data( $_, $data ) ) : ( $_, undef ) } keys(%$key_data) }; } sub get_multi_arrayref { my ( $self, $keys ) = @_; croak "must specify keys" unless defined($keys); my $transformed_keys = [ map { $self->transform_key($_) } @$keys ]; my $key_count = scalar(@$keys); my $keyobjs = $self->get_multi_hashref_objects($transformed_keys); return [ map { my $key = $transformed_keys->[$_]; my $obj = $keyobjs->{$key}; $obj ? $self->get( $key, obj => $obj ) : undef } ( 0 .. $key_count - 1 ) ]; } sub get_multi_hashref { my ( $self, $keys ) = @_; croak "must specify keys" unless defined($keys); my $key_count = scalar(@$keys); my $values = $self->get_multi_arrayref($keys); return { map { ( $keys->[$_], $values->[$_] ) } ( 0 .. $key_count - 1 ) }; } sub set_multi { my $self = shift; $self->store_multi(@_); } sub store_multi { my ( $self, $key_values, $set_options ) = @_; croak "must specify key_values" unless defined($key_values); while ( my ( $key, $value ) = each(%$key_values) ) { $self->set( $key, $value, $set_options ); } } sub remove_multi { my ( $self, $keys ) = @_; croak "must specify keys" unless defined($keys); foreach my $key (@$keys) { $self->remove($key); } } # # KEY TRANSFORMATION # my %escapes; for ( 0 .. 255 ) { $escapes{ chr($_) } = sprintf( "+%02x", $_ ); } my $_fail_hi = sub { croak( sprintf "Can't escape multibyte character \\x{%04X}", ord( $_[0] ) ); }; sub transform_key { my ( $self, $key ) = @_; if ( ref($key) ) { $key = $self->key_serializer->serialize($key); } elsif ( Encode::is_utf8($key) && $key =~ /[^\x00-\xFF]/ ) { $key = $self->encode_key($key); } if ( length($key) > $self->max_key_length ) { $key = $self->digest_key($key); } return $key; } sub digest_key { my ( $self, $key ) = @_; return $self->key_digester->add($key)->hexdigest; } sub encode_key { my ( $self, $key ) = @_; return Encode::encode( utf8 => $key ); } # These will be called by drivers if necessary, and in testing. By default # no escaping/unescaping is necessary. # sub escape_key { $_[1] } sub unescape_key { $_[1] } # May be used by drivers to implement escape_key/unescape_key. # sub escape_for_filename { my ( $self, $key ) = @_; $key =~ s/([^A-Za-z0-9_\=\-\~])/$escapes{$1} || $_fail_hi->($1)/ge; return $key; } sub unescape_for_filename { my ( $self, $key ) = @_; $key =~ s/\+([0-9A-Fa-f]{2})/chr(hex($1))/eg if defined $key; return $key; } sub is_escaped_for_filename { my ( $self, $key ) = @_; return $self->escape_for_filename( $self->unescape_for_filename($key) ) eq $key; } # # LOGGING AND ERROR HANDLING # sub _log_get_result { my $self = shift; my $log = shift; my $msg = shift; $log->debug( sprintf( "%s: %s", $self->_describe_cache_get(@_), $msg ) ); } sub _log_set_result { my $self = shift; my $log = shift; $log->debug( $self->_describe_cache_set(@_) ); } sub _handle_get_error { my $self = shift; my $error = shift; my $key = $_[0]; my $msg = sprintf( "error during %s: %s", $self->_describe_cache_get(@_), $error ); $self->_dispatch_error_msg( $msg, $error, $self->on_get_error(), $key ); } sub _handle_set_error { my ( $self, $error, $obj ) = @_; my $msg = sprintf( "error during %s: %s", $self->_describe_cache_set($obj), $error ); $self->_dispatch_error_msg( $msg, $error, $self->on_set_error(), $obj->key ); } sub _dispatch_error_msg { my ( $self, $msg, $error, $on_error, $key ) = @_; for ($on_error) { ( ref($_) eq 'CODE' ) && do { $_->( $msg, $key, $error ) }; /^log$/ && do { $log->error($msg) }; /^ignore$/ && do { }; /^warn$/ && do { carp $msg }; /^die$/ && do { croak $msg }; } } sub _describe_cache_get { my ( $self, $key, $elapsed_time ) = @_; return sprintf( "cache get for namespace='%s', key='%s', cache='%s'" . ( defined($elapsed_time) ? ", time='%dms'" : "" ), $self->namespace, $key, $self->label, defined($elapsed_time) ? int($elapsed_time) : () ); } sub _describe_cache_set { my ( $self, $obj, $elapsed_time ) = @_; my $expires_str = ( ( $obj->expires_at == CHI_Max_Time ) ? 'never' : Time::Duration::concise( Time::Duration::duration_exact( $obj->expires_at - $obj->created_at ) ) ); return sprintf( "cache set for namespace='%s', key='%s', size=%d, expires='%s', cache='%s'" . ( defined($elapsed_time) ? ", time='%dms'" : "" ), $self->namespace, $obj->key, $obj->size, $expires_str, $self->label, defined($elapsed_time) ? int($elapsed_time) : () ); } 1; __END__ =pod =head1 NAME CHI::Driver - Base class for all CHI drivers =head1 VERSION version 0.61 =head1 DESCRIPTION This is the base class that all CHI drivers inherit from. It provides the methods that one calls on $cache handles, such as get() and set(). See L for documentation on $cache methods, and L for documentation on creating new subclasses of CHI::Driver. =head1 SEE ALSO L =head1 AUTHOR Jonathan Swartz =head1 COPYRIGHT AND LICENSE This software is copyright (c) 2021 by Jonathan Swartz. This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself. =cut