LP#1596595 Hold targeter refactoring and optimization.
authorBill Erickson <berickxx@gmail.com>
Tue, 7 Jun 2016 21:32:14 +0000 (17:32 -0400)
committerKathy Lussier <klussier@masslnc.org>
Fri, 17 Feb 2017 05:58:53 +0000 (00:58 -0500)
* New open-ils.hold-targeter service

* Ports hold targeter code to a Perl utility function, communicating w/
  the DB via cstore instead of storage.

* Adds a new global flag 'circ.holds.retarget_interval' for configuring
  the hold retarget interval in the database.

* Adds a new DB function to regenerating hold copy maps to make map
  deletion and creation more efficient.

* Adds an option for targeting holds in newest to oldest order.

* Caches all org unit settings per targeter run.

* Adds support for "skip_viable" option.  This tells the hold targeter
  to avoid modifying any holds that target viable copies.  AKA "fix
  broken" mode.

  For example, you might run in skip_viable mode with a retarget
  interval of 24hr once a day to repair non-viable holds, then also run
  the targeter in regular mode once a day with a retarget interval of 48
  hours to give staff 2 days to process viable holds.

* Hold target loops logic changes:

 ** Org units with fewer target attempts are prioritized during loop
    processing.  So, instead of segregating org units into 2 categetories,
    those attempted in the current loop and those not attempted, sort those
    not attempted by the number number of times they have been attempted.
    Within each grouping, prioritize by target weight/proximity as before.

 ** All org units that have been attempted less than the max configured
    amount are on the table for targeting, not just those that have been
    targeted less than the current loop max.  If no orgs with
    less-than-current-max attempts are found, try orgs that match the
    current max (but are still less than the configured max).

 ** When activated, target looping treats the pickup lib like any
    other org unit.  If a targeted copy at the pickup lib remains
    un-captured, at re-target time, a copy at a different branch is
    chosen (if one is available) even if other copies at the pickup
    lib are targetable.

* Parallel targeting support baked into service.

  Teach the targeter to process a subset of holds based on the number of
  parallel targeters at play and the parallel targeting slot each targeter
  instance occupies.

  As with the existing hold targeter, group holds by their metarecord to
  avoid multiple targeter processes targeting the same sets of potential
  copies.

* Logging / code refactoring and clean up.

* New hold_targeter_v2.pl script for batch hold targeting.  Existing
  targeter remains for backwards-compat.

hold_targeter_v2.pl options:

--verbose
    Print process counts

--parallel <parallel-process-count>
    Number of parallel hold processors to run.  This overrides any
    value found in opensrf.xml

--target-all
    Target all active holds, regardless of when they were last targeted.

--skip-viable
    Avoid modifying holds that currently target viable copies.
    In other words, only (re)target holds in a non-viable state.

--retarget-interval
    Override the 'circ.holds.retarget_interval' global_flag value.

--parallel-init-sleep
    Time to wait between starting each parallel instance.  Useful for
    avoiding dog-piling the DB.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Chris Sharp <csharp@georgialibraries.org>
Signed-off-by: Kathy Lussier <klussier@masslnc.org>
Open-ILS/examples/opensrf.xml.example
Open-ILS/src/Makefile.am
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/HoldTargeter.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/090.schema.action.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql [new file with mode: 0644]
Open-ILS/src/support-scripts/hold_targeter_v2.pl [new file with mode: 0755]

index 87db4cc..167611a 100644 (file)
@@ -1176,6 +1176,27 @@ vim:et:ts=4:sw=4:
                 </app_settings>
             </open-ils.serial>
 
+            <open-ils.hold-targeter>
+                <keepalive>3</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::HoldTargeter</implementation>
+                <max_requests>17</max_requests>
+                <unix_config>
+                    <unix_sock>open-ils.hold-targeter_unix.sock</unix_sock>
+                    <unix_pid>open-ils.hold-targeter_unix.pid</unix_pid>
+                    <max_requests>1000</max_requests>
+                    <unix_log>open-ils.hold-targeter_unix.log</unix_log>
+                    <min_children>1</min_children>
+                    <max_children>15</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>5</max_spare_children>
+                </unix_config>
+                <app_settings>
+                </app_settings>
+            </open-ils.hold-targeter>
+
+
         </apps>
     </default>
 
@@ -1220,6 +1241,7 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.fielder</appname>  
                 <appname>open-ils.vandelay</appname>  
                 <appname>open-ils.serial</appname>  
+                <appname>open-ils.hold-targeter</appname>  
             </activeapps>
         </localhost>
     </hosts>
index 00740f3..09a6439 100644 (file)
@@ -60,6 +60,7 @@ core_data = @srcdir@/extras/ils_events.xml \
 core_scripts =   $(examples)/oils_ctl.sh \
                 $(supportscr)/fine_generator.pl \
                 $(supportscr)/hold_targeter.pl \
+                $(supportscr)/hold_targeter_v2.pl \
                 $(supportscr)/reshelving_complete.srfsh \
                 $(supportscr)/clear_expired_circ_history.srfsh \
                 $(supportscr)/update_hard_due_dates.srfsh \
index ad6fff2..7dd3611 100644 (file)
@@ -2305,7 +2305,11 @@ sub checkin_retarget {
                 next if ($_->{hold_type} eq 'P');
             }
             # So much for easy stuff, attempt a retarget!
-            my $tresult = $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $_->{id}, $self->copy->id);
+            my $tresult = $U->simplereq(
+                'open-ils.hold-targeter',
+                'open-ils.hold-targeter.target', 
+                {hold => $_->{id}, find_copy => $self->copy->id}
+            );
             if(ref $tresult eq "ARRAY" and scalar @$tresult) {
                 last if(exists $tresult->[0]->{found_copy} and $tresult->[0]->{found_copy});
             }
@@ -3078,8 +3082,8 @@ sub do_hold_notify {
 sub retarget_holds {
     my $self = shift;
     $logger->info("circulator: retargeting holds @{$self->retarget} after opportunistic capture");
-    my $ses = OpenSRF::AppSession->create('open-ils.storage');
-    $ses->request('open-ils.storage.action.hold_request.copy_targeter', undef, $self->retarget);
+    my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
+    $ses->request('open-ils.hold-targeter.target', {hold => $self->retarget});
     # no reason to wait for the return value
     return;
 }
index 52f0893..9f5f42c 100644 (file)
@@ -380,9 +380,9 @@ sub create_hold {
 
     $conn->respond_complete($hold->id);
 
-    $U->storagereq(
-        'open-ils.storage.action.hold_request.copy_targeter',
-        undef, $hold->id ) unless $U->is_true($hold->frozen);
+    $U->simplereq('open-ils.hold-targeter',
+        'open-ils.hold-targeter.target', {hold => $hold->id}
+    ) unless $U->is_true($hold->frozen);
 
     return undef;
 }
@@ -746,7 +746,8 @@ sub uncancel_hold {
     $e->update_action_hold_request($hold) or return $e->die_event;
     $e->commit;
 
-    $U->storagereq('open-ils.storage.action.hold_request.copy_targeter', undef, $hold_id);
+    $U->simplereq('open-ils.hold-targeter',
+        'open-ils.hold-targeter.target', {hold => $hold_id});
 
     return 1;
 }
@@ -1064,15 +1065,16 @@ sub update_hold_impl {
 
     if(!$U->is_true($hold->frozen) && $U->is_true($orig_hold->frozen)) {
         $logger->info("Running targeter on activated hold ".$hold->id);
-        $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
+        $U->simplereq('open-ils.hold-targeter', 
+            'open-ils.hold-targeter.target', {hold => $hold->id});
     }
 
     # a change to mint-condition changes the set of potential copies, so retarget the hold;
     if($U->is_true($hold->mint_condition) and !$U->is_true($orig_hold->mint_condition)) {
         _reset_hold($self, $e->requestor, $hold)
     } elsif($need_retarget && !defined $hold->capture_time()) { # If needed, retarget the hold due to changes
-        $U->storagereq(
-            'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
+        $U->simplereq('open-ils.hold-targeter', 
+            'open-ils.hold-targeter.target', {hold => $hold->id});
     }
 
     return $hold->id;
@@ -1160,7 +1162,8 @@ sub update_hold_if_frozen {
     } else {
         if($U->is_true($orig_hold->frozen)) {
             $logger->info("Running targeter on activated hold ".$hold->id);
-            $U->storagereq( 'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
+            $U->simplereq('open-ils.hold-targeter', 
+                'open-ils.hold-targeter.target', {hold => $hold->id});
         }
     }
 }
@@ -1993,8 +1996,8 @@ sub _reset_hold {
     $e->update_action_hold_request($hold) or return $e->die_event;
     $e->commit;
 
-    $U->storagereq(
-        'open-ils.storage.action.hold_request.copy_targeter', undef, $hold->id );
+    $U->simplereq('open-ils.hold-targeter', 
+        'open-ils.hold-targeter.target', {hold => $hold->id});
 
     return undef;
 }
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/HoldTargeter.pm
new file mode 100644 (file)
index 0000000..037f230
--- /dev/null
@@ -0,0 +1,76 @@
+package OpenILS::Application::HoldTargeter;
+use strict; 
+use warnings;
+use OpenILS::Application;
+use base qw/OpenILS::Application/;
+use OpenILS::Utils::HoldTargeter;
+
+__PACKAGE__->register_method(
+    method    => 'hold_targeter',
+    api_name  => 'open-ils.hold-targeter.target',
+    api_level => 1,
+    argc      => 1,
+    stream    => 1,
+    # Caller is given control over how often to receive responses.
+    max_chunk_size => 0,
+    signature => {
+        desc     => q/Batch or single hold targeter./,
+        params   => [
+            {   name => 'args',
+                desc => 'Hash of targeter options',
+                type => 'hash'
+            }
+        ],
+        return => {
+            desc => q/
+                TODO
+            /
+        }
+    }
+);
+
+# args:
+#
+#   return_count - Return number of holds processed so far instead 
+#       of hold targeter result summary objects.
+#
+#   return_throttle - Only reply each time this many holds have been 
+#       targeted.  This prevents dumping a fast stream of responses
+#       at the client if the client doesn't need them.
+#
+#   See OpenILS::Utils::HoldTargeter::target() docs.
+
+sub hold_targeter {
+    my ($self, $client, $args) = @_;
+
+    my $targeter = OpenILS::Utils::HoldTargeter->new(%$args);
+
+    $targeter->init;
+
+    my $throttle = $args->{return_throttle} || 1;
+    my $count = 0;
+
+    for my $hold_id ($targeter->find_holds_to_target) {
+        $count++;
+
+        my $single = OpenILS::Utils::HoldTargeter::Single->new(
+            parent => $targeter,
+            skip_viable => $args->{skip_viable}
+        );
+
+        $single->target($hold_id);
+
+        if (($count % $throttle) == 0) { 
+            # Time to reply to the caller.  Return either the number
+            # processed thus far or the most recent summary object.
+
+            my $res = $args->{return_count} ? $count : $single->result;
+            $client->respond($res);
+        }
+    }
+
+    return undef;
+}
+
+1;
+
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
new file mode 100644 (file)
index 0000000..2a87180
--- /dev/null
@@ -0,0 +1,1269 @@
+package OpenILS::Utils::HoldTargeter;
+# ---------------------------------------------------------------
+# Copyright (C) 2016 King County Library System
+# Author: Bill Erickson <berickxx@gmail.com>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# ---------------------------------------------------------------
+use strict;
+use warnings;
+use DateTime;
+use OpenSRF::AppSession;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils qw/:datetime/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+our $U = "OpenILS::Application::AppUtils";
+our $dt_parser = DateTime::Format::ISO8601->new;
+
+# See target() for runtime arguments.
+sub new {
+    my ($class, %args) = @_;
+    my $self = {
+        editor => new_editor(),
+        ou_setting_cache => {},
+        %args,
+    };
+    return bless($self, $class);
+}
+
+# Target and retarget holds.
+# By default, targets all holds that need targeting, meaning those that
+# have either never been targeted or those whose prev_check_time exceeds
+# the retarget interval.
+#
+# Returns an array of targeter response objects, one entry per hold
+# targeted.  See also return_count.
+#
+# Optional parameters:
+#
+# hold => <id>
+#  (Re)target a specific hold.
+#
+# return_count => 1
+#   Return the total number of holds processed instead of a result
+#   object for every targeted hold.  Ideal for large batch targeting.
+#
+# retarget_interval => <interval string>
+#   Override the 'circ.holds.retarget_interval' global_flag value.
+#
+# newest_first => 1
+#   Target holds in reverse order of create_time. 
+#
+# skip_viable => 1
+#   Avoid retargeting holds whose current_copy is still viable and
+#   permitted.  This is useful for repairing holds whose targeted copy
+#   has become non-viable for a given hold because its status changed or
+#   policies affecting the hold/copy no longer allow it to be targeted.
+#   This setting can be used in conjunction with any other settings.
+#
+# target_all => 1
+#   USE WITH CAUTION.  Forces (re)targeting of all active holds.  This
+#   is primarily useful or testing.
+#
+# parallel_count => n
+#   Number of parallel targeters running.  This acts as the indication
+#   that other targeter instances are running.
+#
+# parallel_slot => n [starts at 1]
+#   Sets the parallel targeter instance position/slot.  Used to determine
+#   which holds to process to avoid conflicts with other running instances.
+#
+sub target {
+    my ($self, %args) = @_;
+
+    $self->{$_} = $args{$_} for keys %args;
+
+    $self->init;
+
+    my $count = 0;
+    my @responses;
+
+    for my $hold_id ($self->find_holds_to_target) {
+        my $single = OpenILS::Utils::HoldTargeter::Single->new(
+            parent => $self,
+            skip_viable => $args{skip_viable}
+        );
+        $single->target($hold_id);
+        push(@responses, $single->result) unless $self->{return_count};
+        $count++;
+    }
+
+    return $self->{return_count} ? $count : \@responses;
+}
+
+sub find_holds_to_target {
+    my $self = shift;
+
+    return ($self->{hold}) if $self->{hold};
+
+    my $query = {
+        select => {ahr => ['id']},
+        from => 'ahr',
+        where => {
+            capture_time => undef,
+            fulfillment_time => undef,
+            cancel_time => undef,
+            frozen => 'f'
+        },
+        order_by => [
+            {class => 'ahr', field => 'selection_depth', direction => 'DESC'},
+            {class => 'ahr', field => 'request_time'},
+            {class => 'ahr', field => 'prev_check_time'}
+        ]
+    };
+
+    if (!$self->{target_all}) {
+        # Unless we're retargeting all holds, limit to holds that have no
+        # prev_check_time or those whose prev_check_time occurred
+        # before the retarget interval.
+
+        my $date = DateTime->now->subtract(
+            seconds => $self->{retarget_interval});
+
+        $query->{where}->{'-or'} = [
+            {prev_check_time => undef},
+            {prev_check_time => {'<=' => $date->strftime('%F %T%z')}}
+        ];
+    }
+
+    # parallel < 1 means no parallel
+    my $parallel = ($self->{parallel_count} || 0) > 1 ? 
+        $self->{parallel_count} : 0;
+
+    if ($parallel) {
+        # In parallel mode, we need to also grab the metarecord for each hold.
+        $query->{select}->{mmrsm} = ['metarecord'];
+        $query->{from} = {
+            ahr => {
+                rhrr => {
+                    fkey => 'id',
+                    field => 'id',
+                    join => {
+                        mmrsm => {
+                            field => 'source',
+                            fkey => 'bib_record'
+                        }
+                    }
+                }
+            }
+        };
+    }
+
+    # Newest-first sorting cares only about hold create_time.
+    $query->{order_by} =
+        [{class => 'ahr', field => 'request_time', direction => 'DESC'}]
+        if $self->{newest_first};
+
+    my $holds = $self->editor->json_query($query, {substream => 1});
+
+    # In parallel mode, only process holds within the current process
+    # whose metarecord ID modulo the parallel targeter count matches
+    # our paralell targeting slot.  This ensures that no 2 processes
+    # will be operating on the same potential copy sets.
+    #
+    # E.g. Running 5 parallel and we are slot 3 (0-based slot 2) of 5, 
+    # process holds whose metarecord ID's are 2, 7, 12, 17, ...
+    if ($parallel) {
+
+        # Slots are 1-based at the API level, but 0-based for modulo.
+        my $slot = $self->{parallel_slot} - 1;
+
+        my @slot_holds = 
+            grep { ($_->{metarecord} % $parallel) == $slot } @$holds;
+
+        $logger->info(sprintf(
+            "targeter: parallel targeter (slot %d of %d) trimmed ".
+            "targetable holds set down to %d from %d holds",
+            $slot + 1, $parallel, scalar(@slot_holds), scalar(@$holds)
+        ));
+
+        $holds = \@slot_holds;
+    }
+
+    return map {$_->{id}} @$holds;
+}
+
+sub editor {
+    my $self = shift;
+    return $self->{editor};
+}
+
+# Load startup data required by all targeter actions.
+sub init {
+    my $self = shift;
+    my $e = $self->editor;
+
+    my $closed_orgs_query = {
+        close_start => {'<=', 'now'},
+        close_end => {'>=', 'now'}
+    };
+
+    if (!$self->{target_all}) {
+
+        # See if the caller provided an interval
+        my $interval = $self->{retarget_interval};
+
+        if (!$interval) {
+            # See if we have a global flag value for the interval
+
+            $interval = $e->search_config_global_flag({
+                name => 'circ.holds.retarget_interval',
+                enabled => 't'
+            })->[0];
+
+            # If no flag is present, default to a 24-hour retarget interval.
+            $interval = $interval ? $interval->value : '24h';
+        }
+
+        # Convert the interval to seconds for current and future use.
+        $self->{retarget_interval} = interval_to_seconds($interval);
+
+        # An org unit is considered closed for retargeting purposes
+        # if it's closed both now and at the next re-target date.
+
+        my $next_check_time =
+            DateTime->now->add(seconds => $self->{retarget_interval})
+            ->strftime('%F %T%z');
+
+        $closed_orgs_query = {
+            '-and' => [
+                $closed_orgs_query, {
+                    close_start => {'<=', $next_check_time},
+                    close_end => {'>=', $next_check_time}
+                }
+            ]
+        }
+    }
+
+    my $closed =
+        $self->editor->search_actor_org_unit_closed_date($closed_orgs_query);
+
+    # Map of org id to 1. Any org in the map is closed.
+    $self->{closed_orgs} = {map {$_->org_unit => 1} @$closed};
+}
+
+# Org unit setting fetch+cache
+sub get_ou_setting {
+    my ($self, $org_id, $setting) = @_;
+    my $c = $self->{ou_setting_cache};
+
+    $c->{$org_id} = {} unless $c->{$org_id};
+
+    $c->{$org_id}->{$setting} =
+        $U->ou_ancestor_setting_value($org_id, $setting, $self->{editor})
+        unless exists $c->{$org_id}->{$setting};
+
+    return $c->{$org_id}->{$setting};
+}
+
+# -----------------------------------------------------------------------
+# Knows how to target a single hold.
+# -----------------------------------------------------------------------
+package OpenILS::Utils::HoldTargeter::Single;
+use strict;
+use warnings;
+use DateTime;
+use OpenSRF::AppSession;
+use OpenSRF::Utils qw/:datetime/;
+use OpenSRF::Utils::Logger qw(:logger);
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+sub new {
+    my ($class, %args) = @_;
+    my $self = {
+        %args,
+        editor => new_editor(),
+        error => 0,
+        success => 0
+    };
+    return bless($self, $class);
+}
+
+# Parent targeter object.
+sub parent {
+    my ($self, $parent) = @_;
+    $self->{parent} = $parent if $parent;
+    return $self->{parent};
+}
+
+sub hold_id {
+    my ($self, $hold_id) = @_;
+    $self->{hold_id} = $hold_id if $hold_id;
+    return $self->{hold_id};
+}
+
+sub hold {
+    my ($self, $hold) = @_;
+    $self->{hold} = $hold if $hold;
+    return $self->{hold};
+}
+
+# Debug message
+sub message {
+    my ($self, $message) = @_;
+    $self->{message} = $message if $message;
+    return $self->{message} || '';
+}
+
+# True if the hold was successfully targeted.
+sub success {
+    my ($self, $success) = @_;
+    $self->{success} = $success if defined $success;
+    return $self->{success};
+}
+
+# True if targeting exited early on an unrecoverable error.
+sub error {
+    my ($self, $error) = @_;
+    $self->{error} = $error if defined $error;
+    return $self->{error};
+}
+
+sub editor {
+    my $self = shift;
+    return $self->{editor};
+}
+
+sub result {
+    my $self = shift;
+
+    return {
+        hold    => $self->hold_id,
+        error   => $self->error,
+        success => $self->success,
+        message => $self->message,
+        target  => $self->hold ? $self->hold->current_copy : undef,
+        old_target => $self->{previous_copy_id},
+        found_copy => $self->{found_copy},
+        eligible_copies => $self->{eligible_copy_count}
+    };
+}
+
+# List of potential copies in the form of slim hashes.  This list
+# evolves as copies are filtered as they are deemed non-targetable.
+sub copies {
+    my ($self, $copies) = @_;
+    $self->{copies} = $copies if $copies;
+    return $self->{copies};
+}
+
+# Final set of potential copies, including those that may not be
+# currently targetable, that may be eligible for recall processing.
+sub recall_copies {
+    my ($self, $recall_copies) = @_;
+    $self->{recall_copies} = $recall_copies if $recall_copies;
+    return $self->{recall_copies};
+}
+
+# Maps copy ID's to their hold proximity
+sub copy_prox_map {
+    my ($self, $copy_prox_map) = @_;
+    $self->{copy_prox_map} = $copy_prox_map if $copy_prox_map;
+    return $self->{copy_prox_map};
+}
+
+sub log_hold {
+    my ($self, $msg, $err) = @_;
+    my $level = $err ? 'error' : 'info';
+    $logger->$level("targeter: [hold ".$self->hold_id."] $msg");
+}
+
+# Captures the exit message, rolls back the cstore transaction/connection,
+# and returns false.
+# is_error : log the final message and editor event at ERR level.
+sub exit_targeter {
+    my ($self, $msg, $is_error) = @_;
+
+    $self->message($msg);
+    my $log = "exiting => $msg";
+
+    if ($is_error) {
+        # On error, roll back and capture the last editor event for logging.
+
+        my $evt = $self->editor->die_event;
+        $log .= " [".$evt->{textcode}."]" if $evt;
+
+        $self->error(1);
+        $self->log_hold($log, 1);
+
+    } else {
+        # Attempt a rollback and disconnect when each hold exits
+        # to avoid the possibility of leaving cstore's pinned.
+        # Note: ->rollback is a no-op when a ->commit has already occured.
+
+        $self->editor->rollback;
+        $self->log_hold($log);
+    }
+
+    return 0;
+}
+
+# Cancel expired holds and kick off the A/T no-target event.  Returns
+# true (i.e. keep going) if the hold is not expired.  Returns false if
+# the hold is canceled or a non-recoverable error occcurred.
+sub handle_expired_hold {
+    my $self = shift;
+    my $hold = $self->hold;
+
+    return 1 unless $hold->expire_time;
+
+    my $ex_time =
+        $dt_parser->parse_datetime(cleanse_ISO8601($hold->expire_time));
+    return 1 unless DateTime->compare($ex_time, DateTime->now) < 0;
+
+    # Hold is expired --
+
+    $hold->cancel_time('now');
+    $hold->cancel_cause(1); # == un-targeted expiration
+
+    $self->editor->update_action_hold_request($hold)
+        or return $self->exit_targeter("Error canceling hold", 1);
+
+    $self->editor->commit;
+
+    # Fire the A/T handler, but don't wait for a response.
+    OpenSRF::AppSession->create('open-ils.trigger')->request(
+        'open-ils.trigger.event.autocreate',
+        'hold_request.cancel.expire_no_target',
+        $hold, $hold->pickup_lib
+    );
+
+    return $self->exit_targeter("Hold is expired");
+}
+
+# Find potential copies for hold mapping and targeting.
+sub get_hold_copies {
+    my $self = shift;
+    my $e = $self->editor;
+    my $hold = $self->hold;
+
+    my $hold_target = $hold->target;
+    my $hold_type   = $hold->hold_type;
+    my $org_unit    = $hold->selection_ou;
+    my $org_depth   = $hold->selection_depth || 0;
+
+    my $query = {
+        select => {
+            acp => ['id', 'status', 'circ_lib'],
+            ahr => ['current_copy']
+        },
+        from => {
+            acp => {
+                # Tag copies that are in use by other holds so we don't
+                # try to target them for our hold.
+                ahr => {
+                    type => 'left',
+                    fkey => 'id', # acp.id
+                    field => 'current_copy',
+                    filter => {
+                        fulfillment_time => undef,
+                        cancel_time => undef,
+                        id => {'!=' => $self->hold_id}
+                    }
+                }
+            }
+        },
+        where => {
+            '+acp' => {
+                deleted => 'f',
+                circ_lib => {
+                    in => {
+                        select => {
+                            aou => [{
+                                transform => 'actor.org_unit_descendants',
+                                column => 'id',
+                                result_field => 'id',
+                                params => [$org_depth]
+                            }],
+                            },
+                        from => 'aou',
+                        where => {id => $org_unit}
+                    }
+                }
+            }
+        }
+    };
+
+    unless ($hold_type eq 'R' || $hold_type eq 'F') {
+        # Add the holdability filters to the copy query, unless
+        # we're processing a Recall or Force hold, which bypass most
+        # holdability checks.
+
+        $query->{from}->{acp}->{acpl} = {
+            field => 'id',
+            filter => {holdable => 't', deleted => 'f'},
+            fkey => 'location'
+        };
+
+        $query->{from}->{acp}->{ccs} = {
+            field => 'id',
+            filter => {holdable => 't'},
+            fkey => 'status'
+        };
+
+        $query->{where}->{'+acp'}->{holdable} = 't';
+        $query->{where}->{'+acp'}->{mint_condition} = 't'
+            if $U->is_true($hold->mint_condition);
+    }
+
+    unless ($hold_type eq 'C' || $hold_type eq 'I' || $hold_type eq 'P') {
+        # For volume and higher level holds, avoid targeting copies that
+        # act as instances of monograph parts.
+        $query->{from}->{acp}->{acpm} = {
+            type => 'left',
+            field => 'target_copy',
+            fkey => 'id'
+        };
+
+        $query->{where}->{'+acpm'}->{id} = undef;
+    }
+
+    if ($hold_type eq 'C' || $hold_type eq 'R' || $hold_type eq 'F') {
+
+        $query->{where}->{'+acp'}->{id} = $hold_target;
+
+    } elsif ($hold_type eq 'V') {
+
+        $query->{where}->{'+acp'}->{call_number} = $hold_target;
+
+    } elsif ($hold_type eq 'P') {
+
+        $query->{from}->{acp}->{acpm} = {
+            field  => 'target_copy',
+            fkey   => 'id',
+            filter => {part => $hold_target},
+        };
+
+    } elsif ($hold_type eq 'I') {
+
+        $query->{from}->{acp}->{sitem} = {
+            field  => 'unit',
+            fkey   => 'id',
+            filter => {issuance => $hold_target},
+        };
+
+    } elsif ($hold_type eq 'T') {
+
+        $query->{from}->{acp}->{acn} = {
+            field  => 'id',
+            fkey   => 'call_number',
+            'join' => {
+                bre => {
+                    field  => 'id',
+                    filter => {id => $hold_target},
+                    fkey   => 'record'
+                }
+            }
+        };
+
+    } else { # Metarecord hold
+
+        $query->{from}->{acp}->{acn} = {
+            field => 'id',
+            fkey  => 'call_number',
+            join  => {
+                bre => {
+                    field => 'id',
+                    fkey  => 'record',
+                    join  => {
+                        mmrsm => {
+                            field  => 'source',
+                            fkey   => 'id',
+                            filter => {metarecord => $hold_target},
+                        }
+                    }
+                }
+            }
+        };
+
+        if ($hold->holdable_formats) {
+            # Compile the JSON-encoded metarecord holdable formats
+            # to an Intarray query_int string.
+            my $query_int = $e->json_query({
+                from => [
+                    'metabib.compile_composite_attr',
+                    $hold->holdable_formats
+                ]
+            })->[0];
+            # TODO: ^- any way to add this as a filter in the main query?
+
+            if ($query_int) {
+                # Only pull potential copies from records that satisfy
+                # the holdable formats query.
+                my $qint = $query_int->{'metabib.compile_composite_attr'};
+                $query->{from}->{acp}->{acn}->{join}->{bre}->{join}->{mravl} = {
+                    field  => 'source',
+                    fkey   => 'id',
+                    filter => {vlist => {'@@' => $qint}}
+                }
+            }
+        }
+    }
+
+    my $copies = $e->json_query($query);
+    $self->{eligible_copy_count} = scalar(@$copies);
+
+    $self->log_hold($self->{eligible_copy_count}." potential copies");
+
+    # Let the caller know we encountered the copy they were interested in.
+    $self->{found_copy} = 1 if $self->{find_copy}
+        && grep {$_->{id} eq $self->{find_copy}} @$copies;
+
+    $self->copies($copies);
+
+    return 1;
+}
+
+# Delete and rebuild copy maps
+sub update_copy_maps {
+    my $self = shift;
+    my $e = $self->editor;
+
+    my $resp = $e->json_query({from => [
+        'action.hold_request_regen_copy_maps',
+        $self->hold_id,
+        '{' . join(',', map {$_->{id}} @{$self->copies}) . '}'
+    ]});
+
+    # The above call can fail if another process is updating
+    # copy maps for this hold at the same time.
+    return 1 if $resp && @$resp;
+
+    return $self->exit_targeter("Error creating hold copy maps", 1);
+}
+
+# Returns a map of proximity values to arrays of copy hashes.
+# The copy hash arrays are weighted consistent with the org unit hold
+# target weight, meaning that a given copy may appear more than once
+# in its proximity list.
+sub compile_weighted_proximity_map {
+    my $self = shift;
+
+    # Collect copy proximity info (generated via DB trigger)
+    # from our newly create copy maps.
+    my $hold_copy_maps = $self->editor->json_query({
+        select => {ahcm => ['target_copy', 'proximity']},
+        from => 'ahcm',
+        where => {hold => $self->hold_id}
+    });
+
+    my %copy_prox_map =
+        map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
+
+    my %prox_map;
+    for my $copy_hash (@{$self->copies}) {
+        my $prox = $copy_prox_map{$copy_hash->{id}};
+        $prox_map{$prox} ||= [];
+
+        my $weight = $self->parent->get_ou_setting(
+            $copy_hash->{circ_lib},
+            'circ.holds.org_unit_target_weight') || 1;
+
+        # Each copy is added to the list once per target weight.
+        push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
+    }
+
+    return $self->{weighted_prox_map} = \%prox_map;
+}
+
+# Returns true if filtering completed without error, false otherwise.
+sub filter_closed_date_copies {
+    my $self = shift;
+
+    my @filtered_copies;
+    for my $copy_hash (@{$self->copies}) {
+        my $clib = $copy_hash->{circ_lib};
+
+        if ($self->parent->{closed_orgs}->{$clib}) {
+            # Org unit is currently closed.  See if it matters.
+
+            my $ous = $self->hold->pickup_lib eq $clib ?
+                'circ.holds.target_when_closed_if_at_pickup_lib' :
+                'circ.holds.target_when_closed';
+
+            unless ($self->parent->get_ou_setting($clib, $ous)) {
+                # Targeting not allowed at this circ lib when its closed
+
+                $self->log_hold("skipping copy ".
+                    $copy_hash->{id}."at closed org $clib");
+
+                next;
+            }
+
+        }
+
+        push(@filtered_copies, $copy_hash);
+    }
+
+    # Update our in-progress list of copies to reflect the filtered set.
+    $self->copies(\@filtered_copies);
+
+    return 1;
+}
+
+# Limit the set of potential copies to those that are
+# in a targetable status.
+# Returns true if filtering completes without error, false otherwise.
+sub filter_copies_by_status {
+    my $self = shift;
+
+    $self->copies([
+        grep {$_->{status} == 0 || $_->{status} == 7} @{$self->copies}
+    ]);
+
+    # Track checked out copies for later recall
+    $self->recall_copies([grep {$_->{status} == 1} @{$self->copies}]);
+
+    return 1;
+}
+
+# Remove copies that are currently targeted by other holds.
+# Returns true if filtering completes without error, false otherwise.
+sub filter_copies_in_use {
+    my $self = shift;
+
+    # A copy with a 'current_copy' value means it's in use by another hold.
+    $self->copies([
+        grep {!$_->{current_copy}} @{$self->copies}
+    ]);
+
+    return 1;
+}
+
+# Returns true if inspection completed without error, false otherwise.
+sub inspect_previous_target {
+    my $self = shift;
+    my $hold = $self->hold;
+    my @copies = @{$self->copies};
+
+    # no previous target
+    return 1 unless my $prev_id = $hold->current_copy;
+
+    $self->{previous_copy_id} = $prev_id;
+
+    # See if the previous copy is in our list of valid copies.
+    my ($prev) = grep {$_->{id} eq $prev_id} @copies;
+
+    # exit if previous target is no longer valid.
+    return 1 unless $prev;
+
+    if ($self->{skip_viable}) {
+        # In skip_viable mode, leave the hold as-is if the existing
+        # current_copy is still permitted.
+        # Note: viability checking is done this late in the process
+        # (specifically after other potential copies have been fetched)
+        # because we first need to confirm the current_copy is a valid
+        # potential copy (e.g. it's holdable, non-deleted, etc.), which
+        # copy_is_permitted, which only checks hold matrix policies,
+        # does not check.
+
+        return $self->exit_targeter("Skipping with viable target = $prev_id")
+            if $self->copy_is_permitted($prev);
+
+        # Previous copy is now confirmed non-viable.
+
+    } else {
+
+        # Previous copy may be targetable.  Keep it around for later
+        # in case we need to confirm its viability and re-use it.
+        $self->{valid_previous_copy} = $prev;
+    }
+
+    # Remove the previous copy from the working set of potential copies.
+    # It will be revisited later if needed.
+    $self->copies([grep {$_->{id} ne $prev_id} @copies]);
+
+    return 1;
+}
+
+# Returns true if we have at least one potential copy remaining, thus
+# targeting should continue.  Otherwise, the hold is updated to reflect
+# that there is no target and returns false to stop targeting.
+sub handle_no_copies {
+    my ($self, %args) = @_;
+
+    if (!$args{force}) {
+        # If 'force' is set, the caller is saying that all copies have
+        # failed.  Otherwise, see if we have any copies left to inspect.
+        return 1 if @{$self->copies} || $self->{valid_previous_copy};
+    }
+
+    # At this point, all copies have been inspected and none
+    # have yielded a targetable item.
+
+    if ($args{process_recalls}) {
+        # See if we have any copies/circs to recall.
+        return unless $self->process_recalls;
+    }
+
+    my $hold = $self->hold;
+    $hold->clear_current_copy;
+    $hold->prev_check_time('now');
+
+    $self->editor->update_action_hold_request($hold)
+        or return $self->exit_targeter("Error updating hold request", 1);
+
+    $self->editor->commit;
+    return $self->exit_targeter("Hold has no targetable copies");
+}
+
+# Force and recall holds bypass validity tests.  Returns the first
+# (and presumably only) copy in our list of valid copies when a
+# F or R hold is encountered.  Returns undef otherwise.
+sub attempt_force_recall_target {
+    my $self = shift;
+    return $self->copies->[0] if
+        $self->hold->hold_type eq 'R' || $self->hold->hold_type eq 'F';
+    return undef;
+}
+
+sub attempt_to_find_copy {
+    my $self = shift;
+
+    return undef unless @{$self->copies};
+
+    my $max_loops = $self->parent->get_ou_setting(
+        $self->hold->pickup_lib,
+        'circ.holds.max_org_unit_target_loops'
+    );
+
+    return $self->target_by_org_loops($max_loops) if $max_loops;
+
+    # When not using target loops, targeting is based solely on
+    # proximity and org unit target weight.
+    $self->compile_weighted_proximity_map;
+
+    return $self->find_nearest_copy;
+}
+
+# Returns 2 arrays.  The first is a list of copies whose circ lib's
+# unfulfilled target count matches the provided $iter value.  The 
+# second list is all other copies, returned for convenience.
+sub get_copies_at_loop_iter {
+    my ($self, $targeted_libs, $iter) = @_;
+
+    my @iter_copies; # copies to try now.
+    my @remaining_copies; # copies to try later
+
+    for my $copy (@{$self->copies}) {
+        my $match = 0;
+
+        if ($iter == 0) {
+            # Start with copies at circ libs that have never been targeted.
+            $match = 1 unless grep {
+                $copy->{circ_lib} eq $_->{circ_lib}} @$targeted_libs;
+
+        } else {
+            # Find copies at branches whose target count
+            # matches the current (non-zero) loop depth.
+
+            $match = 1 if grep {
+                $_->{count} eq $iter &&
+                $_->{circ_lib} eq $copy->{circ_lib}
+            } @$targeted_libs;
+        }
+
+        if ($match) {
+            push(@iter_copies, $copy);
+        } else {
+            push(@remaining_copies, $copy);
+        }
+    }
+
+    $self->log_hold(
+        sprintf("%d potential copies at max-loops iteration level $iter. ".
+            "%d remain to be tested at a higher loop iteration level.",
+            scalar(@iter_copies), 
+            scalar(@remaining_copies)
+        )
+    );
+
+    return (\@iter_copies, \@remaining_copies);
+}
+
+# Find libs whose unfulfilled target count is less than the maximum
+# configured loop count.  Target copies in order of their circ_lib's
+# target count (starting at 0) and moving up.  Copies within each
+# loop count group are weighted based on configured hold weight.  If
+# no copies in a given group are targetable, move up to the next
+# unfulfilled target level.  Keep doing this until all potential
+# copies have been tried or max targets loops is exceeded.
+# Returns a targetable copy if one is found, undef otherwise.
+sub target_by_org_loops {
+    my ($self, $max_loops) = @_;
+
+    my $targeted_libs = $self->editor->json_query({
+        select => {aufhl => ['circ_lib', 'count']},
+        from => 'aufhl',
+        where => {hold => $self->hold_id},
+        order_by => [{class => 'aufhl', field => 'count'}]
+    });
+
+    my $max_tried = 0; # Highest per-lib target attempts.
+    foreach (@$targeted_libs) {
+        $max_tried = $_->{count} if $_->{count} > $max_tried;
+    }
+
+    $self->log_hold("Max lib attempts is $max_tried. ".
+        scalar(@$targeted_libs)." libs have been targeted at least once.");
+
+    # $loop_iter represents per-lib target attemtps already made.
+    # When loop_iter equals max loops, all libs with targetable copies
+    # have been targeted the maximum number of times.  loop_iter starts
+    # at 0 to pick up libs that have never been targeted.
+    my $loop_iter = -1;
+    while (++$loop_iter < $max_loops) {
+
+        # Ran out of copies to try before exceeding max target loops.
+        # Nothing else to do here.
+        return undef unless @{$self->copies};
+
+        my ($iter_copies, $remaining_copies) = 
+            $self->get_copies_at_loop_iter($targeted_libs, $loop_iter);
+
+        next unless @$iter_copies;
+
+        $self->copies($iter_copies);
+
+        # Update the proximity map to only include the copies
+        # from this loop-depth iteration.
+        $self->compile_weighted_proximity_map;
+
+        my $copy = $self->find_nearest_copy;
+        return $copy if $copy; # found one!
+
+        # No targetable copy at the current target loop.
+        # Update our current copy set to the not-yet-tested copies.
+        $self->copies($remaining_copies);
+    }
+
+    # Avoid canceling the hold with exceeds-loops unless at least one
+    # lib has been targeted max_loops times.  Otherwise, the hold goes
+    # back to waiting for another copy (or retargets its current copy).
+    return undef if $max_tried < $max_loops;
+
+    # At least one lib has been targeted max-loops times and zero 
+    # other copies are targetable.  All options have been exhausted.
+    return $self->handle_exceeds_target_loops;
+}
+
+# Cancel the hold, fire the no-target A/T event handler, and exit.
+sub handle_exceeds_target_loops {
+    my $self = shift;
+    my $e = $self->editor;
+    my $hold = $self->hold;
+
+    $hold->cancel_time('now');
+    $hold->cancel_cause(1); # = un-targeted expiration
+
+    $e->update_action_hold_request($hold)
+        or return $self->exit_targeter("Error updating hold request", 1);
+
+    $e->commit;
+
+    # Fire the A/T handler, but don't wait for a response.
+    OpenSRF::AppSession->create('open-ils.trigger')->request(
+        'open-ils.trigger.event.autocreate',
+        'hold_request.cancel.expire_no_target',
+        $hold, $hold->pickup_lib
+    );
+
+    return $self->exit_targeter("Hold exceeded max target loops");
+}
+
+# When all else fails, see if we can reuse the previously targeted copy.
+sub attempt_prev_copy_retarget {
+    my $self = shift;
+
+    # earlier target logic can in some cases cancel the hold.
+    return undef if $self->hold->cancel_time;
+
+    my $prev_copy = $self->{valid_previous_copy};
+    return undef unless $prev_copy;
+
+    $self->log_hold("attempting to re-target previously ".
+        "targeted copy for hold ".$self->hold_id);
+
+    if ($self->copy_is_permitted($prev_copy)) {
+        $self->log_hold("retargeting the previously ".
+            "targeted copy [".$prev_copy->{id}."]" );
+        return $prev_copy;
+    }
+
+    return undef;
+}
+
+# Returns the closest copy by proximity that is a confirmed valid
+# targetable copy.
+sub find_nearest_copy {
+    my $self = shift;
+    my %prox_map = %{$self->{weighted_prox_map}};
+    my $hold = $self->hold;
+    my %seen;
+
+    # Pick a copy at random from each tier of the proximity map,
+    # starting at the lowest proximity and working up, until a
+    # copy is found that is suitable for targeting.
+    for my $prox (sort {$a <=> $b} keys %prox_map) {
+        my @copies = @{$prox_map{$prox}};
+        next unless @copies;
+
+        my $rand = int(rand(scalar(@copies)));
+
+        while (my ($c) = splice(@copies, $rand, 1)) {
+            $rand = int(rand(scalar(@copies)));
+            next if $seen{$c->{id}};
+
+            return $c if $self->copy_is_permitted($c);
+            $seen{$c->{id}} = 1;
+
+            last unless(@copies);
+        }
+    }
+
+    return undef;
+}
+
+# Returns true if the provided copy passes the hold permit test for our
+# hold and can be used for targeting.
+# When a copy fails the test, it is removed from $self->copies.
+sub copy_is_permitted {
+    my ($self, $copy) = @_;
+    return 0 unless $copy;
+
+    my $resp = $self->editor->json_query({
+        from => [
+            'action.hold_retarget_permit_test',
+            $self->hold->request_lib,
+            $self->hold->pickup_lib,
+            $copy->{id},
+            $self->hold->usr,
+            $self->hold->requestor
+        ]
+    });
+
+    return 1 if $U->is_true($resp->[0]->{success});
+
+    # Copy is confirmed non-viable.
+    # Remove it from our potentials list.
+    $self->copies([
+        grep {$_->{id} ne $copy->{id}} @{$self->copies}
+    ]);
+
+    return 0;
+}
+
+# Sets hold.current_copy to the provided copy.
+sub apply_copy_target {
+    my ($self, $copy) = @_;
+    my $e = $self->editor;
+    my $hold = $self->hold;
+
+    $hold->current_copy($copy->{id});
+    $hold->prev_check_time('now');
+
+    $e->update_action_hold_request($hold)
+        or return $self->exit_targeter("Error updating hold request", 1);
+
+    $e->commit;
+    $self->{success} = 1;
+    return $self->exit_targeter("successfully targeted copy ".$copy->{id});
+}
+
+# Creates a new row in action.unfulfilled_hold_list for our hold.
+# Returns 1 if all is OK, false on error.
+sub log_unfulfilled_hold {
+    my $self = shift;
+    return 1 unless my $prev_id = $self->{previous_copy_id};
+    my $e = $self->editor;
+
+    $self->log_hold(
+        "hold was not fulfilled by previous targeted copy $prev_id");
+
+    my $circ_lib;
+    if ($self->{valid_previous_copy}) {
+        $circ_lib = $self->{valid_previous_copy}->{circ_lib};
+
+    } else {
+        # We don't have a handle on the previous copy to get its
+        # circ lib.  Fetch it.
+        $circ_lib = $e->retrieve_asset_copy($prev_id)->circ_lib;
+    }
+
+    my $unful = Fieldmapper::action::unfulfilled_hold_list->new;
+    $unful->hold($self->hold_id);
+    $unful->circ_lib($circ_lib);
+    $unful->current_copy($prev_id);
+
+    $e->create_action_unfulfilled_hold_list($unful) or
+        return $self->exit_targeter("Error creating unfulfilled_hold_list", 1);
+
+    return 1;
+}
+
+sub process_recalls {
+    my $self = shift;
+    my $e = $self->editor;
+
+    my $pu_lib = $self->hold->pickup_lib;
+
+    my $threshold =
+        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_threshold')
+        or return 1;
+
+    my $interval =
+        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_return_interval')
+        or return 1;
+
+    # Give me the ID of every checked out copy living at the hold
+    # pickup library.
+    my @copy_ids = map {$_->{id}}
+        grep {$_->{circ_lib} eq $pu_lib} @{$self->recall_copies};
+
+    return 1 unless @copy_ids;
+
+    my $circ = $e->search_action_circulation([
+        {   target_copy => \@copy_ids,
+            checkin_time => undef,
+            duration => {'>' => $threshold}
+        }, {
+            order_by => 'due_date',
+            limit => 1
+        }
+    ])->[0];
+
+    return unless $circ;
+
+    $self->log_hold("recalling circ ".$circ->id);
+
+    # Give the user a new due date of either a full recall threshold,
+    # or the return interval, whichever is further in the future.
+    my $threshold_date = DateTime::Format::ISO8601
+        ->parse_datetime(cleanse_ISO8601($circ->xact_start))
+        ->add(seconds => interval_to_seconds($threshold))
+        ->iso8601();
+
+    my $return_date = DateTime->now(time_zone => 'local')->add(
+        seconds => interval_to_seconds($interval))->iso8601();
+
+    if (DateTime->compare(
+        DateTime::Format::ISO8601->parse_datetime($threshold_date),
+        DateTime::Format::ISO8601->parse_datetime($return_date)) == 1) {
+        $return_date = $threshold_date;
+    }
+
+    my %update_fields = (
+        due_date => $return_date,
+        renewal_remaining => 0,
+    );
+
+    my $fine_rules =
+        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_fine_rules');
+
+    # If the OU hasn't defined new fine rules for recalls, keep them
+    # as they were
+    if ($fine_rules) {
+        $self->log_hold("applying recall fine rules: $fine_rules");
+        my $rules = OpenSRF::Utils::JSON->JSON2perl($fine_rules);
+        $update_fields{recurring_fine} = $rules->[0];
+        $update_fields{fine_interval} = $rules->[1];
+        $update_fields{max_fine} = $rules->[2];
+    }
+
+    # Copy updated fields into circ object.
+    $circ->$_($update_fields{$_}) for keys %update_fields;
+
+    $e->update_action_circulation($circ)
+        or return $self->exit_targeter(
+            "Error updating circulation object in process_recalls", 1);
+
+    # Create trigger event for notifying current user
+    my $ses = OpenSRF::AppSession->create('open-ils.trigger');
+    $ses->request('open-ils.trigger.event.autocreate',
+        'circ.recall.target', $circ, $circ->circ_lib);
+
+    return 1;
+}
+
+# Target a single hold request
+sub target {
+    my ($self, $hold_id) = @_;
+
+    my $e = $self->editor;
+    $self->hold_id($hold_id);
+
+    $self->log_hold("processing...");
+
+    $e->xact_begin;
+
+    my $hold = $e->retrieve_action_hold_request($hold_id)
+        or return $self->exit_targeter("No hold found", 1);
+
+    return $self->exit_targeter("Hold is not eligible for targeting")
+        if $hold->capture_time     ||
+           $hold->cancel_time      ||
+           $hold->fulfillment_time ||
+           $U->is_true($hold->frozen);
+
+    $self->hold($hold);
+
+    return unless $self->handle_expired_hold;
+    return unless $self->get_hold_copies;
+    return unless $self->update_copy_maps;
+
+    # Confirm that we have something to work on.  If we have no
+    # copies at this point, there's also nothing to recall.
+    return unless $self->handle_no_copies;
+
+    # Trim the set of working copies down to those that are
+    # currently targetable.
+    return unless $self->filter_copies_by_status;
+    return unless $self->filter_copies_in_use;
+    return unless $self->filter_closed_date_copies;
+
+    # Set aside the previously targeted copy for later use as needed.
+    # Code may exit here in skip_viable mode if the existing
+    # current_copy value is still viable.
+    return unless $self->inspect_previous_target;
+
+    # Log that the hold was not captured.
+    return unless $self->log_unfulfilled_hold;
+
+    # Confirm again we have something to work on.  If we have no
+    # targetable copies now, there may be a copy that can be recalled.
+    return unless $self->handle_no_copies(process_recalls => 1);
+
+    # At this point, the working list of copies has been trimmed to
+    # those that are currently targetable at a superficial level.  
+    # (They are holdable and available).  Now the code steps through 
+    # these copies in order of priority and pickup lib proximity to 
+    # find a copy that is confirmed targetable by policy.
+
+    my $copy = $self->attempt_force_recall_target ||
+               $self->attempt_to_find_copy        ||
+               $self->attempt_prev_copy_retarget;
+
+    # See if one of the above attempt* calls canceled the hold as a side
+    # effect of looking for a copy to target.
+    return if $hold->cancel_time;
+
+    return $self->apply_copy_target($copy) if $copy;
+
+    # No targetable copy was found.  Fire the no-copy handler.
+    $self->handle_no_copies(force => 1, process_recalls => 1);
+}
+
+
+
index 923ba4e..5832034 100644 (file)
@@ -477,6 +477,13 @@ CREATE TABLE action.hold_copy_map (
 -- CREATE INDEX acm_hold_idx ON action.hold_copy_map (hold);
 CREATE INDEX acm_copy_idx ON action.hold_copy_map (target_copy);
 
+CREATE OR REPLACE FUNCTION
+    action.hold_request_regen_copy_maps(
+        hold_id INTEGER, copy_ids INTEGER[]) RETURNS VOID AS $$
+    DELETE FROM action.hold_copy_map WHERE hold = $1;
+    INSERT INTO action.hold_copy_map (hold, target_copy) SELECT $1, UNNEST($2);
+$$ LANGUAGE SQL;
+
 CREATE TABLE action.transit_copy (
        id                      SERIAL                          PRIMARY KEY,
        source_send_time        TIMESTAMP WITH TIME ZONE,
index 0b6b731..0440c6e 100644 (file)
@@ -16534,3 +16534,16 @@ VALUES
      'Display copy location checkin alert for in-house-use',
      'coust', 'description'),
  'bool');
+
+INSERT INTO config.global_flag (name, label, value, enabled) VALUES (
+    'circ.holds.retarget_interval',
+    oils_i18n_gettext(
+        'circ.holds.retarget_interval',
+        'Holds Retarget Interval', 
+        'cgf',
+        'label'
+    ),
+    '24h',
+    TRUE
+);
+
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql
new file mode 100644 (file)
index 0000000..ba584f0
--- /dev/null
@@ -0,0 +1,25 @@
+BEGIN;
+
+CREATE OR REPLACE FUNCTION
+    action.hold_request_regen_copy_maps(
+        hold_id INTEGER, copy_ids INTEGER[]) RETURNS VOID AS $$
+    DELETE FROM action.hold_copy_map WHERE hold = $1;
+    INSERT INTO action.hold_copy_map (hold, target_copy) SELECT $1, UNNEST($2);
+$$ LANGUAGE SQL;
+
+-- DATA
+
+INSERT INTO config.global_flag (name, label, value, enabled) VALUES (
+    'circ.holds.retarget_interval',
+    oils_i18n_gettext(
+        'circ.holds.retarget_interval',
+        'Holds Retarget Interval', 
+        'cgf',
+        'label'
+    ),
+    '24h',
+    TRUE
+);
+
+COMMIT;
+
diff --git a/Open-ILS/src/support-scripts/hold_targeter_v2.pl b/Open-ILS/src/support-scripts/hold_targeter_v2.pl
new file mode 100755 (executable)
index 0000000..7f342ef
--- /dev/null
@@ -0,0 +1,191 @@
+#!/usr/bin/perl
+use strict; 
+use warnings;
+use Getopt::Long;
+use OpenSRF::System;
+use OpenSRF::AppSession;
+use OpenSRF::Utils::SettingsClient;
+use OpenILS::Utils::Fieldmapper;
+#----------------------------------------------------------------
+# Batch hold (re)targeter
+#
+# Usage:
+#   ./hold_targeter.pl /openils/conf/opensrf_core.xml
+#----------------------------------------------------------------
+
+my $help;
+my $osrf_config = '/openils/conf/opensrf_core.xml';
+my $lockfile = '/tmp/hold_targeter-LOCK';
+my $parallel = 0;
+my $verbose = 0;
+my $target_all;
+my $skip_viable;
+my $retarget_interval;
+my $recv_timeout = 3600;
+my $parallel_init_sleep = 0;
+
+# how often the server sends a summary reply per backend.
+my $return_throttle = 50;
+
+GetOptions(
+    'osrf-config=s'     => \$osrf_config,
+    'lockfile=s'        => \$lockfile,
+    'parallel=i'        => \$parallel,
+    'verbose'           => \$verbose,
+    'target-all'        => \$target_all,
+    'skip-viable'       => \$skip_viable,
+    'retarget-interval' => \$retarget_interval,
+    'parallel-init-sleep=i' => \$parallel_init_sleep,
+    'help'              => \$help
+) || die "\nSee --help for more\n";
+
+sub help {
+    print <<HELP;
+
+Batch hold targeter.
+
+$0 \
+    --osrf-config /openils/conf/opensrf_core.xml \
+    --lockfile /tmp/hold_targeter-LOCK \
+    --parallel 3
+    --verbose
+
+General Options
+
+    --osrf-config [/openils/conf/opensrf_core.xml] 
+        OpenSRF config file.
+
+    --lockfile [/tmp/hold_targeter-LOCK]
+        Full path to lock file
+
+
+    --verbose
+        Print process counts
+
+Targeting Options
+
+    --parallel <parallel-process-count>
+        Number of parallel hold processors to run.  This overrides any
+        value found in opensrf.xml
+
+    --parallel-init-sleep <seconds=0>
+        Number of seconds to wait before starting each subsequent
+        parallel targeter instance.  This gives each targeter backend
+        time to run the large targetable holds query before the next
+        kicks off, so they don't all hit the database at once.
+
+        Defaults to no sleep.
+
+    --target-all
+        Target all active holds, regardless of when they were last targeted.
+
+    --skip-viable
+        Avoid modifying holds that currently target viable copies.  In
+        other words, only (re)target holds in a non-viable state.
+
+    --retarget-interval
+        Override the 'circ.holds.retarget_interval' global_flag value. 
+
+HELP
+
+    exit(0);
+}
+
+help() if $help;
+
+sub init {
+
+    OpenSRF::System->bootstrap_client(config_file => $osrf_config);
+    Fieldmapper->import(
+        IDL => OpenSRF::Utils::SettingsClient->new->config_value("IDL"));
+
+    if (!$parallel) {
+        my $settings = OpenSRF::Utils::SettingsClient->new;
+        $parallel = $settings->config_value(hold_targeter => 'parallel') || 1;
+    }
+}
+
+sub run_batches {
+
+    # Hanging all of the parallel requests off the same app session
+    # lets us operate the same as a MultiSession batch with additional
+    # fine-grained controls over the receive timeout and real-time
+    # response handling.
+    my $ses = OpenSRF::AppSession->create('open-ils.hold-targeter');
+
+    my @reqs;
+    for my $slot (1..$parallel) {
+
+        if ($slot > 1 && $parallel_init_sleep) {
+            $verbose && print "Sleeping $parallel_init_sleep ".
+                "seconds before targeter slot=$slot launch\n";
+            sleep $parallel_init_sleep;
+        }
+
+        $verbose && print "Starting targeter slot=$slot\n";
+
+        my $req = $ses->request(
+            'open-ils.hold-targeter.target', {
+                return_count    => 1,
+                return_throttle => $return_throttle,
+                parallel_count  => $parallel,
+                parallel_slot   => $slot,
+                skip_viable     => $skip_viable,
+                target_all      => $target_all,
+                retarget_interval => $retarget_interval
+            }
+        );
+
+        $req->{_parallel_slot} = $slot; # for grouping/logging below
+        push(@reqs, $req);
+    }
+
+    while (@reqs) {
+        my $start = time;
+        $ses->queue_wait($recv_timeout); # wait for a response
+
+        # As a fail-safe, exit if no responses have arrived 
+        # within the timeout interval.
+        last if (time - $start) >= $recv_timeout;
+
+        for my $req (@reqs) {
+            # Pull all responses off the receive queues.
+            while (my $resp = $req->recv(0)) {
+                $verbose && print sprintf(
+                    "Targeter [%d] processed %d holds\n",
+                    $req->{_parallel_slot},
+                    $resp->content
+                );
+            }
+        }
+
+        @reqs = grep {!$_->complete} @reqs;
+    }
+}
+
+# ----
+
+die "I seem to be running already. If not remove $lockfile, try again\n" 
+    if -e $lockfile;
+
+open(LOCK, ">$lockfile") or die "Cannot open lock file: $lockfile : $@\n";
+print LOCK $$ or die "Cannot write to lock file: $lockfile : $@\n";
+close LOCK;
+   
+eval { # Make sure we can delete the lock file.
+
+    init();
+
+    my $start = time;
+
+    run_batches();
+
+    my $minutes = sprintf('%0.2f', (time - $start) / 60.0);
+
+    $verbose && print "Processing took $minutes minutes.\n";
+};
+
+warn "Hold processing exited with error: $@\n" if $@;
+
+unlink $lockfile;
+