[open-ils-commits] [GIT] Evergreen ILS branch master updated. eee584eaee6595cf82275bdb734b7d17c9820eef

Evergreen Git git at git.evergreen-ils.org
Fri Feb 17 01:18:10 EST 2017


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "Evergreen ILS".

The branch, master has been updated
       via  eee584eaee6595cf82275bdb734b7d17c9820eef (commit)
       via  895f8bd153b9d98ad0920f85f5b1d2c95d0833b1 (commit)
       via  85e73bc2c8caa94b14c78c44866411ca192b2c82 (commit)
       via  129a38be37e524298767e01a991f7e83a3ad25ea (commit)
       via  dbcd6ec54caf3f4571edfadca184e67b517b1bba (commit)
       via  8c72a71371a534e2c56be128d073d8ec0e228e44 (commit)
       via  276a7cc22c9f23ab2cd1870a6675c5769a5d08c6 (commit)
       via  8b5487e0c6ccc92c175e8ae24c1b2ddd7ca19d27 (commit)
       via  2bb30f42a97bab8f025bbc78376fdf011eda14b9 (commit)
       via  3203abfbb73465d3a2cd1082eef0563b1a700d1c (commit)
       via  75625c2f8866b3890c76defa4f7d9e74182b2fdd (commit)
      from  922b4b317cabda8ce9c87902be4f8b53555c84db (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit eee584eaee6595cf82275bdb734b7d17c9820eef
Author: Kathy Lussier <klussier at masslnc.org>
Date:   Fri Feb 17 01:12:08 2017 -0500

    LP#1596595: Stamping upgrade scripts for hold targeter refactoring
    
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql
index e7323d5..954324b 100644
--- a/Open-ILS/src/sql/Pg/002.schema.config.sql
+++ b/Open-ILS/src/sql/Pg/002.schema.config.sql
@@ -91,7 +91,7 @@ CREATE TRIGGER no_overlapping_deps
     BEFORE INSERT OR UPDATE ON config.db_patch_dependencies
     FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates');
 
-INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1018', :eg_version); -- csharp/Dyrcona/gmcharlt
+INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1020', :eg_version); -- berick/csharp/kmlussier
 
 CREATE TABLE config.bib_source (
 	id		SERIAL	PRIMARY KEY,
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql b/Open-ILS/src/sql/Pg/upgrade/1019.schema.hold_targeter.sql
similarity index 89%
rename from Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql
rename to Open-ILS/src/sql/Pg/upgrade/1019.schema.hold_targeter.sql
index ba584f0..61f3462 100644
--- a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1019.schema.hold_targeter.sql
@@ -1,5 +1,7 @@
 BEGIN;
 
+SELECT evergreen.upgrade_deps_block_check('1019', :eg_version);
+
 CREATE OR REPLACE FUNCTION
     action.hold_request_regen_copy_maps(
         hold_id INTEGER, copy_ids INTEGER[]) RETURNS VOID AS $$
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.batch_settings_by_org.sql b/Open-ILS/src/sql/Pg/upgrade/1020.schema.batch_settings_by_org.sql
similarity index 91%
rename from Open-ILS/src/sql/Pg/upgrade/YYYY.schema.batch_settings_by_org.sql
rename to Open-ILS/src/sql/Pg/upgrade/1020.schema.batch_settings_by_org.sql
index 567183f..cc566ae 100644
--- a/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.batch_settings_by_org.sql
+++ b/Open-ILS/src/sql/Pg/upgrade/1020.schema.batch_settings_by_org.sql
@@ -1,5 +1,7 @@
 BEGIN;
 
+SELECT evergreen.upgrade_deps_block_check('1020', :eg_version);
+
 CREATE OR REPLACE FUNCTION actor.org_unit_ancestor_setting_batch_by_org(
     setting_name TEXT, org_ids INTEGER[]) 
     RETURNS SETOF actor.org_unit_setting AS 

commit 895f8bd153b9d98ad0920f85f5b1d2c95d0833b1
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Feb 7 13:59:10 2017 -0500

    LP#1596595 Targeter leverages batch AOUS lookups
    
    Use the batch-by-org AOUS lookup function to fetch settings with one
    cstore call across a wide set of org units.  This reduces the number of
    cstore calls required, significantly in some cases, for single-use hold
    targeter instances (like placement time targeting, checkin retargeting).
    
    Specifically, in cases where a hold has targetable copies at multiple
    circ libs, only one cstore call is needed for each of the
    circ.holds.org_unit_target_weight and
    circ.holds.target_when_closed settings, as opposed to one cstore call
    for each per circ lib.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
index 394944f..f7cc947 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
@@ -272,6 +272,33 @@ sub get_ou_setting {
     return $c->{$org_id}->{$setting};
 }
 
+# Fetches settings for a batch of org units.  Useful for pre-caching
+# setting values across a wide variety of org units without having to
+# make a lookup call for every org unit.
+# First checks to see if a value exists in the cache.
+# For all non-cached values, looks up in DB, then caches the value.
+sub precache_batch_ou_settings {
+    my ($self, $org_ids, $setting, $e) = @_;
+
+    $e ||= $self->{editor};
+    my $c = $self->{ou_setting_cache};
+
+    my @orgs;
+    for my $org_id (@$org_ids) {
+        next if exists $c->{$org_id}->{$setting};
+        push (@orgs, $org_id);
+    }
+
+    return unless @orgs; # value aready cached for all requested orgs.
+
+    my %settings = 
+        $U->ou_ancestor_setting_batch_by_org_insecure(\@orgs, $setting, $e);
+
+    for my $org_id (keys %settings) {
+        $c->{$org_id}->{$setting} = $settings{$org_id}->{value};
+    }
+}
+
 # -----------------------------------------------------------------------
 # Knows how to target a single hold.
 # -----------------------------------------------------------------------
@@ -649,6 +676,14 @@ sub update_copy_maps {
     return $self->exit_targeter("Error creating hold copy maps", 1);
 }
 
+# unique set of circ lib IDs for all in-progress copy blobs.
+sub get_copy_circ_libs {
+    my $self = shift;
+    my %orgs = map {$_->{circ_lib} => 1} @{$self->copies};
+    return [keys %orgs];
+}
+
+
 # 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
@@ -667,6 +702,11 @@ sub compile_weighted_proximity_map {
     my %copy_prox_map =
         map {$_->{target_copy} => $_->{proximity}} @$hold_copy_maps;
 
+    # Pre-fetch the org setting value for all circ libs so that
+    # later calls can reference the cached value.
+    $self->parent->precache_batch_ou_settings($self->get_copy_circ_libs, 
+        'circ.holds.org_unit_target_weight', $self->editor);
+
     my %prox_map;
     for my $copy_hash (@{$self->copies}) {
         my $prox = $copy_prox_map{$copy_hash->{id}};
@@ -687,6 +727,22 @@ sub compile_weighted_proximity_map {
 sub filter_closed_date_copies {
     my $self = shift;
 
+    # Pre-fetch the org setting value for all represented circ libs that
+    # are closed, minuse the pickup_lib, since it has its own special setting.
+    my $circ_libs = $self->get_copy_circ_libs;
+    $circ_libs = [
+        grep {
+            $self->parent->{closed_orgs}->{$_} && 
+            $_ ne $self->hold->pickup_lib
+        } @$circ_libs
+    ];
+
+    # If none of the represented circ libs are closed, we're done here.
+    return 1 unless @$circ_libs;
+
+    $self->parent->precache_batch_ou_settings(
+        $circ_libs, 'circ.holds.target_when_closed', $self->editor);
+
     my @filtered_copies;
     for my $copy_hash (@{$self->copies}) {
         my $clib = $copy_hash->{circ_lib};
@@ -703,7 +759,7 @@ sub filter_closed_date_copies {
                 # Targeting not allowed at this circ lib when its closed
 
                 $self->log_hold("skipping copy ".
-                    $copy_hash->{id}."at closed org $clib");
+                    $copy_hash->{id}." at closed org $clib");
 
                 next;
             }

commit 85e73bc2c8caa94b14c78c44866411ca192b2c82
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Feb 7 12:25:01 2017 -0500

    LP#1596595 AOUS lookup batch by org id
    
    Org unit setting value lookup for batches of org units, instead of the
    traditional batches by setting name.
    
    Perl live test included.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
index dca778d..a8c6c9f 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
@@ -1333,6 +1333,44 @@ sub ou_ancestor_setting_batch_insecure {
     return %result;
 }
 
+# Returns a hash of hashes like so:
+# { 
+#   $lookup_org_id => {org => $context_org, value => $setting_value},
+#   $lookup_org_id2 => {org => $context_org2, value => $setting_value2},
+#   $lookup_org_id3 => {} # example of no setting value exists
+#   ...
+# }
+sub ou_ancestor_setting_batch_by_org_insecure {
+    my ($self, $org_ids, $name, $e) = @_;
+
+    $e ||= OpenILS::Utils::CStoreEditor->new();
+    my %result = map { $_ => {value => undef} } @$org_ids;
+
+    my $query = {
+        from => [
+            'actor.org_unit_ancestor_setting_batch_by_org',
+            $name, '{' . join(',', @$org_ids) . '}'
+        ]
+    };
+
+    # DB func returns an array of settings matching the order of the
+    # list of org unit IDs.  If the setting does not contain a valid
+    # ->id value, then no setting value exists for that org unit.
+    my $settings = $e->json_query($query);
+    for my $idx (0 .. $#$org_ids) {
+        my $setting = $settings->[$idx];
+        my $org_id = $org_ids->[$idx];
+
+        next unless $setting->{id}; # null ID means no value is present.
+
+        $result{$org_id}->{org} = $setting->{org_unit};
+        $result{$org_id}->{value} = 
+            OpenSRF::Utils::JSON->JSON2perl($setting->{value});
+    }
+
+    return %result;
+}
+
 # returns the ISO8601 string representation of the requested epoch in GMT
 sub epoch2ISO8601 {
     my( $self, $epoch ) = @_;
diff --git a/Open-ILS/src/perlmods/live_t/21-batch-org-settings.t b/Open-ILS/src/perlmods/live_t/21-batch-org-settings.t
new file mode 100644
index 0000000..27d88b5
--- /dev/null
+++ b/Open-ILS/src/perlmods/live_t/21-batch-org-settings.t
@@ -0,0 +1,43 @@
+#!perl
+use strict; use warnings;
+use Test::More tests => 180; # 15 orgs * 12 settings
+use OpenILS::Utils::TestUtils;
+use OpenILS::Application::AppUtils;
+my $U = 'OpenILS::Application::AppUtils';
+
+diag("Tests batch org setting retrieval");
+
+my $script = OpenILS::Utils::TestUtils->new();
+$script->bootstrap;
+
+my $org_ids = [1 .. 15];
+# All settings at time of writing.  None of these have view perms.
+my @settings = qw/
+    circ.patron_search.diacritic_insensitive
+    circ.checkout_auto_renew_age
+    cat.label.font.weight
+    cat.spine.line.height
+    circ.grace.extend
+    cat.label.font.size
+    circ.booking_reservation.default_elbow_room
+    cat.spine.line.width
+    lib.info_url
+    circ.hold_go_home_interval
+    cat.label.font.family
+    cat.spine.line.margin
+/;
+
+# compare the values returned from the batch-by-org setting to the
+# traditional setting value lookup call.
+for my $setting (@settings) {
+    my %batch_settings = 
+        $U->ou_ancestor_setting_batch_by_org_insecure($org_ids, $setting);
+
+    for my $org_id (@$org_ids) {
+        my $value = $U->ou_ancestor_setting_value($org_id, $setting);
+        is($value, $batch_settings{$org_id}->{value}, 
+            "Value matches for setting $setting and org $org_id");
+    }
+}
+
+
diff --git a/Open-ILS/src/sql/Pg/020.schema.functions.sql b/Open-ILS/src/sql/Pg/020.schema.functions.sql
index 7cc5be0..b256087 100644
--- a/Open-ILS/src/sql/Pg/020.schema.functions.sql
+++ b/Open-ILS/src/sql/Pg/020.schema.functions.sql
@@ -321,6 +321,26 @@ For each setting name passed, search "up" the org_unit tree until
 we find the first occurrence of an org_unit_setting with the given name.
 $$;
 
+CREATE OR REPLACE FUNCTION actor.org_unit_ancestor_setting_batch_by_org(
+    setting_name TEXT, org_ids INTEGER[]) 
+    RETURNS SETOF actor.org_unit_setting AS 
+$FUNK$
+DECLARE
+    setting RECORD;
+    org_id INTEGER;
+BEGIN
+    /*  Returns one actor.org_unit_setting row per org unit ID provided.
+        When no setting exists for a given org unit, the setting row
+        will contain all empty values. */
+    FOREACH org_id IN ARRAY org_ids LOOP
+        SELECT INTO setting * FROM 
+            actor.org_unit_ancestor_setting(setting_name, org_id);
+        RETURN NEXT setting;
+    END LOOP;
+    RETURN;
+END;
+$FUNK$ LANGUAGE plpgsql STABLE;
+
 CREATE OR REPLACE FUNCTION evergreen.get_barcodes(select_ou INT, type TEXT, in_barcode TEXT) RETURNS SETOF evergreen.barcode_set AS $$
 DECLARE
     cur_barcode TEXT;
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.batch_settings_by_org.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.batch_settings_by_org.sql
new file mode 100644
index 0000000..567183f
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.batch_settings_by_org.sql
@@ -0,0 +1,24 @@
+BEGIN;
+
+CREATE OR REPLACE FUNCTION actor.org_unit_ancestor_setting_batch_by_org(
+    setting_name TEXT, org_ids INTEGER[]) 
+    RETURNS SETOF actor.org_unit_setting AS 
+$FUNK$
+DECLARE
+    setting RECORD;
+    org_id INTEGER;
+BEGIN
+    /*  Returns one actor.org_unit_setting row per org unit ID provided.
+        When no setting exists for a given org unit, the setting row
+        will contain all empty values. */
+    FOREACH org_id IN ARRAY org_ids LOOP
+        SELECT INTO setting * FROM 
+            actor.org_unit_ancestor_setting(setting_name, org_id);
+        RETURN NEXT setting;
+    END LOOP;
+    RETURN;
+END;
+$FUNK$ LANGUAGE plpgsql STABLE;
+
+COMMIT;
+

commit 129a38be37e524298767e01a991f7e83a3ad25ea
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Feb 7 10:27:52 2017 -0500

    LP#1596595 Targeter use child editor for settings
    
    Use the CStoreEditor linked to the ::Single (child) targeter object when
    possible to fetch org unit setting values.
    
    In cases where settings for many org units have to be retrieved at once,
    the settings lookups can take long enough that the in-transaction editor
    on the child targeter can timeout.  Using the child's editor directly
    for the lookups will prevent this timeout and make the lookups a little
    bit faster, since a new connect will not be required for each lookup.
    
    This timeout scenario can occur with settings like
    circ.holds.max_org_unit_target_loops and
    circ.holds.target_when_closed, when there is wide variety of targetable
    copies, because each have to be fetched once per target-able copy circ lib.
    
    A secondary optimization would be a batch org setting lookup that
    batches on org unit instead of setting name.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
index 75b8e84..394944f 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
@@ -256,14 +256,17 @@ sub init {
 }
 
 # Org unit setting fetch+cache
+# $e is the OpenILS::Utils::HoldTargeter::Single editor.  Use it if
+# provided to avoid timeouts on the in-transaction child editor.
 sub get_ou_setting {
-    my ($self, $org_id, $setting) = @_;
+    my ($self, $org_id, $setting, $e) = @_;
     my $c = $self->{ou_setting_cache};
 
+    $e ||= $self->{editor};
     $c->{$org_id} = {} unless $c->{$org_id};
 
     $c->{$org_id}->{$setting} =
-        $U->ou_ancestor_setting_value($org_id, $setting, $self->{editor})
+        $U->ou_ancestor_setting_value($org_id, $setting, $e)
         unless exists $c->{$org_id}->{$setting};
 
     return $c->{$org_id}->{$setting};
@@ -671,7 +674,7 @@ sub compile_weighted_proximity_map {
 
         my $weight = $self->parent->get_ou_setting(
             $copy_hash->{circ_lib},
-            'circ.holds.org_unit_target_weight') || 1;
+            'circ.holds.org_unit_target_weight', $self->editor) || 1;
 
         # Each copy is added to the list once per target weight.
         push(@{$prox_map{$prox}}, $copy_hash) foreach (1 .. $weight);
@@ -695,7 +698,8 @@ sub filter_closed_date_copies {
                 'circ.holds.target_when_closed_if_at_pickup_lib' :
                 'circ.holds.target_when_closed';
 
-            unless ($self->parent->get_ou_setting($clib, $ous)) {
+            unless (
+                $self->parent->get_ou_setting($clib, $ous, $self->editor)) {
                 # Targeting not allowed at this circ lib when its closed
 
                 $self->log_hold("skipping copy ".
@@ -838,7 +842,8 @@ sub attempt_to_find_copy {
 
     my $max_loops = $self->parent->get_ou_setting(
         $self->hold->pickup_lib,
-        'circ.holds.max_org_unit_target_loops'
+        'circ.holds.max_org_unit_target_loops',
+        $self->editor
     );
 
     return $self->target_by_org_loops($max_loops) if $max_loops;
@@ -1122,11 +1127,13 @@ sub process_recalls {
     my $pu_lib = $self->hold->pickup_lib;
 
     my $threshold =
-        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_threshold')
+        $self->parent->get_ou_setting(
+            $pu_lib, 'circ.holds.recall_threshold', $self->editor)
         or return 1;
 
     my $interval =
-        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_return_interval')
+        $self->parent->get_ou_setting(
+            $pu_lib, 'circ.holds.recall_return_interval', $self->editor)
         or return 1;
 
     # Give me the ID of every checked out copy living at the hold
@@ -1172,7 +1179,8 @@ sub process_recalls {
     );
 
     my $fine_rules =
-        $self->parent->get_ou_setting($pu_lib, 'circ.holds.recall_fine_rules');
+        $self->parent->get_ou_setting(
+            $pu_lib, 'circ.holds.recall_fine_rules', $self->editor);
 
     # If the OU hasn't defined new fine rules for recalls, keep them
     # as they were

commit dbcd6ec54caf3f4571edfadca184e67b517b1bba
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Feb 2 17:00:24 2017 -0500

    LP#1596595 Targeter accepts a list of hold ID's
    
    Allow the caller to pass a list of hold ID's (consistent with the
    current hold targeter).  This resolves the issue where the checkin
    process attempts to retarget a set of holds via a single targeter call.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
index 125b395..75b8e84 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
@@ -47,8 +47,9 @@ sub new {
 #
 # Optional parameters:
 #
-# hold => <id>
-#  (Re)target a specific hold.
+# hold => <id> / [<id>, <id>, ...]
+#  (Re)target one or more specific holds.  Specified as a single hold ID
+#  or an array ref of hold IDs.
 #
 # return_count => 1
 #   Return the total number of holds processed instead of a result
@@ -105,7 +106,11 @@ sub target {
 sub find_holds_to_target {
     my $self = shift;
 
-    return ($self->{hold}) if $self->{hold};
+    if ($self->{hold}) {
+        # $self->{hold} can be a single hold ID or an array ref of hold IDs
+        return @{$self->{hold}} if ref $self->{hold} eq 'ARRAY';
+        return ($self->{hold});
+    }
 
     my $query = {
         select => {ahr => ['id']},

commit 8c72a71371a534e2c56be128d073d8ec0e228e44
Author: Bill Erickson <berickxx at gmail.com>
Date:   Thu Jan 19 12:21:11 2017 -0500

    LP#1596595 Hold copy permit test thinko repair
    
    Pickup and requesting org unit IDs were passed in the wrong order to
    the copy permit test.  This resulted in some items, particularly age
    protected copies, appearing on the holds pull list even though they were
    not (yet) permitted for hold capture at a remoted library.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
index 5555c86..125b395 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
@@ -1043,8 +1043,8 @@ sub copy_is_permitted {
     my $resp = $self->editor->json_query({
         from => [
             'action.hold_retarget_permit_test',
-            $self->hold->request_lib,
             $self->hold->pickup_lib,
+            $self->hold->request_lib,
             $copy->{id},
             $self->hold->usr,
             $self->hold->requestor

commit 276a7cc22c9f23ab2cd1870a6675c5769a5d08c6
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Dec 5 15:46:41 2016 -0500

    LP#1596595 Find parallel holds in main query
    
    Identify holds to process by which metarecord the hold is (ultimately)
    linked to within the main holds query instead of via a secondary filter.
    This avoids the overhead of starting a new batch of hold targeters,
    where each process has to fetch all possible holds, then filter down to
    those targetable within the current parallel slot.  In thise case, each
    process only retrieves the holds it plans to process.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
index 2a87180..5555c86 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
@@ -143,7 +143,6 @@ sub find_holds_to_target {
 
     if ($parallel) {
         # In parallel mode, we need to also grab the metarecord for each hold.
-        $query->{select}->{mmrsm} = ['metarecord'];
         $query->{from} = {
             ahr => {
                 rhrr => {
@@ -158,6 +157,28 @@ sub find_holds_to_target {
                 }
             }
         };
+
+        # 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, ...
+        # WHERE MOD(mmrsm.id, 5) = 2
+
+        # Slots are 1-based at the API level, but 0-based for modulo.
+        my $slot = $self->{parallel_slot} - 1;
+
+        $query->{where}->{'+mmrsm'} = {
+            id => {
+                '=' => {
+                    transform => 'mod',
+                    value => $slot,
+                    params => [$parallel]
+                }
+            }
+        };
     }
 
     # Newest-first sorting cares only about hold create_time.
@@ -167,30 +188,6 @@ sub find_holds_to_target {
 
     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;
 }
 

commit 8b5487e0c6ccc92c175e8ae24c1b2ddd7ca19d27
Author: Bill Erickson <berickxx at gmail.com>
Date:   Fri Jul 1 11:26:26 2016 -0400

    LP#1596595 Hold targeter release notes
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/docs/RELEASE_NOTES_NEXT/Administration/hold-targeter.adoc b/docs/RELEASE_NOTES_NEXT/Administration/hold-targeter.adoc
new file mode 100644
index 0000000..d9f373a
--- /dev/null
+++ b/docs/RELEASE_NOTES_NEXT/Administration/hold-targeter.adoc
@@ -0,0 +1,91 @@
+Batch Hold Targeter Speed-up and New Features
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Adds a new open-ils.hold-targeter service, supporting new targeting options
+and runtime optimizations to speed up targeting.  The service is launched
+from a new targeting script, hold_targeter_v2.pl (default location:
+/openils/bin/hold_targeter_v2.pl).
+
+This code has no effect on the existing hold targeter, which is still
+available as of this release and functions as before.
+
+New Features/Options
+++++++++++++++++++++
+
+* Adds a global configuration flag 'circ.holds.retarget_interval' for 
+  setting the hold retarget interval.
+
+* --target-all option forces the targeter to process all active
+  holds, regardless of when they were last targeted.
+
+* --retarget-interval option make is possible to override the new
+  'circ.holds.retarget_interval' setting via the command line 
+  when calling the hold targeter.
+
+* --skip-viable option causes the hold targeter to avoid modifying 
+  the currently targeted copy (i.e. the copy on the pull list) for holds 
+  that target a viable (capturable) copy.  
+  
+  For skipped holds, no entry is added to the unfulfilled_hold_list.
+  The set of potential copies (hold copy maps) are refreshed for all
+  processed holds, regardless of target viability.
+
+  This option is useful for 1.) finding targets for holds that require 
+  new targets and 2.) adding new/modified copies to the potential copy 
+  lists (for op capture) more frequently than you may want to do full
+  retargeting of all holds.
+
+* --newest-first option processes holds in reverse order of request_time,
+  so that newer holds are (re)targeted first.  This is primarily useful
+  when a large backlog of old, un-targetable holds exist.  With 
+  --newest-first, the older holds will be processed last.
+
+* --parallel option overrides the parallel settings found in opensrf.xml
+  for simpler modification and testing.
+
+* --lockfile option allows the caller to specifiy a lock file instead
+  of using the default /tmp/hold_targeter-LOCK
+
+* --verbose option prints progress info to STDOUT, showing the number of
+  holds processed per parallel targeter instance.
+
+* When configured, hold target loops cycle through all org units (with 
+  targetable copies) instead of repeatedly targeting copies at the pickup
+  library when multiple targetable copies exist at the pickup library.
+
+* When configured, hold target loops prioritize (targetable) org units
+  first by the number of previous target attempts, then by their 
+  weight/proximity.  This effectivy back-fills org units that had no
+  targetable copies during earlier target loops so that they are 
+  targeted as many times as other org units (to the extent possible, 
+  anyway).
+
+Examples
+++++++++
+
+* Traditional daily hold targeter with a value set for 
+  'circ.holds.retarget_interval'.
+
+[code,sh]
+--------------------------------------------------------------------------
+/openils/bin/hold_targeter_v2.pl
+--------------------------------------------------------------------------
+
+* (Re)target non-viable holds twice a day, only processing holds that 
+  have never been targeter or those that have not been re-targeted in
+  the last 12 hours.
+
+[code,sh]
+--------------------------------------------------------------------------
+/openils/bin/hold_targeter_v2.pl --skp-viable --retarget-interval "12h"
+--------------------------------------------------------------------------
+
+* (Re)target non-viable holds twice a day, processing all holds regardles
+  of when or if they were targeted before, running 3 targeters in
+  parallel.
+
+[code,sh]
+--------------------------------------------------------------------------
+/openils/bin/hold_targeter_v2.pl --skip-viable --target-all --parallel 3
+--------------------------------------------------------------------------
+

commit 2bb30f42a97bab8f025bbc78376fdf011eda14b9
Author: Bill Erickson <berickxx at gmail.com>
Date:   Mon Aug 15 12:27:51 2016 -0400

    LP#1596595 Hold targeter perl live tests
    
    1. Batch of tests for concerto hold 1 / title hold
    2. Batch of tests for concerto hold 265 / metarecord hold with
       holdable_formats restriction.
    3. --skip-viable test
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/src/perlmods/live_t/20-hold-targeter.t b/Open-ILS/src/perlmods/live_t/20-hold-targeter.t
new file mode 100644
index 0000000..c4bb7cc
--- /dev/null
+++ b/Open-ILS/src/perlmods/live_t/20-hold-targeter.t
@@ -0,0 +1,157 @@
+#!perl
+use strict;
+use warnings;
+
+use Test::More tests => 15;
+diag("General hold targeter tests");
+
+use OpenILS::Const qw/:const/;
+use OpenILS::Utils::TestUtils;
+use OpenILS::Utils::HoldTargeter;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+
+my $script = OpenILS::Utils::TestUtils->new();
+my $targeter = OpenILS::Utils::HoldTargeter->new;
+my $e = new_editor();
+
+$script->bootstrap;
+$e->init;
+
+# == Targeting Concerto hold 1.  Title hold.
+
+my $hold_id = 1;
+my $result = $targeter->target(hold => $hold_id)->[0];
+
+ok($result->{success}, "Targeting hold $hold_id returned success");
+
+# Concerto hold 1 targets record 2 with a pickup_lib of 5.  
+# There are several viable copies with circ lib 5.
+my $current_copy = $e->retrieve_asset_copy($result->{target});
+is($current_copy->circ_lib.'', '5', 'Targeted copy lives at pickup lib');
+
+my $maps = $e->search_action_hold_copy_map([
+    {hold => $hold_id},
+    {
+        flesh => 2, 
+        flesh_fields => {ahcm => ['target_copy'], acp => ['call_number']}
+    }
+]);
+
+is(scalar(@$maps), 25, "Hold $hold_id has 25 mapped potential copies");
+
+is(scalar(grep {$_->target_copy->call_number->record != 2} @$maps), 0,
+    'All targeted copies belong to the targeted bib record');
+
+# Retarget to confirm a new copy is selected and that the previously
+# targeted item has a new entry in action.unfulfilled_hold_list.
+
+$result = $targeter->target(hold => $hold_id)->[0];
+
+isnt($result->{target}, $current_copy->id, 
+    'Second targeter run on hold 1 selected targeted a different copy');
+
+my $unfulfilled = $e->search_action_unfulfilled_hold_list(
+    {hold => $hold_id, current_copy => $current_copy->id})->[0];
+
+isnt($unfulfilled, undef, 'Previous copy has unfulfilled hold entry');
+
+my $prev_target = $result->{target};
+
+$result = $targeter->target(hold => $hold_id, skip_viable => 1)->[0];
+
+is($result->{target}, $prev_target, 
+    "Hold $hold_id target remains the same with --skip-viable");
+
+$maps = $e->search_action_hold_copy_map({hold => $hold_id});
+
+is(scalar(@$maps), 25, 
+    "Hold $hold_id retains 25 mapped potential copies with --skip-viable");
+
+
+# == Metarecord hold tests
+#
+# Concerto hold 263 is a metarecord hold with pickup_lib 4, target 42, and 
+# holdable_format '{"0":[{"_attr":"mr_hold_format","_val":"score"}]}'.
+
+$hold_id = 263;
+$result = $targeter->target(hold => $hold_id)->[0];
+
+ok($result->{success}, "Targeting hold $hold_id returned success");
+
+$current_copy = $e->retrieve_asset_copy($result->{target});
+is($current_copy->circ_lib.'', '9', 'Targeted copy lives at pickup lib');
+
+$maps = $e->search_action_hold_copy_map([
+    {hold => $hold_id},
+    {
+        flesh => 2, 
+        flesh_fields => {ahcm => ['target_copy'], acp => ['call_number']}
+    }
+]);
+
+is(scalar(@$maps), 22, "Hold $hold_id has 22 mapped potential copies");
+
+# Only 1 bib record (45) links to metarecord 42.  It also satisfies the 
+# holdable_format criteria.
+is(scalar(grep {$_->target_copy->call_number->record != 45} @$maps), 0,
+    'All targeted copies belong to the targeted bib record');
+
+# Bib 101 has mr_hold_format 'book'.  Link it to the targeted metabib
+# and confirm the targeter does not select it.
+
+$e->xact_begin;
+my $mrmap_101 = $e->search_metabib_metarecord_source_map({source => 101})->[0];
+my $orig_101_mr = $mrmap_101->metarecord;
+$mrmap_101->metarecord(42);
+$e->update_metabib_metarecord_source_map($mrmap_101) or die $e->die_event;
+
+# Temporarily point the original bib (42) at another metarecord
+
+my $mrmap_42 = $e->search_metabib_metarecord_source_map({source => 45})->[0];
+my $orig_42_mr = $mrmap_42->metarecord;
+$mrmap_42->metarecord(1);
+$e->update_metabib_metarecord_source_map($mrmap_42) or die $e->die_event;
+$e->xact_commit;
+
+# This time no copies should be targeted, since no records match
+# the holdable_formats criteria.
+$result = $targeter->target(hold => $hold_id)->[0];
+
+isnt($result->{success}, 1, 
+    'Unable to target MR hold without copies matching holdable_format');
+
+$maps = $e->search_action_hold_copy_map({hold => $hold_id});
+
+is(scalar(@$maps), 0, 
+    'No potential copies exist that match the holdable_format criteria');
+
+# Now remove the holdable format restriction and copies belonging to
+# record 101 should now be acceptable potential copies.
+$e->xact_begin;
+my $hold = $e->retrieve_action_hold_request($hold_id);
+$hold->clear_holdable_formats;
+$e->update_action_hold_request($hold) or die $e->die_event;
+$e->xact_commit;
+
+$result = $targeter->target(hold => $hold_id)->[0];
+
+$current_copy = $e->retrieve_asset_copy([
+    $result->{target},
+    {flesh => 1, flesh_fields => {acp => ['call_number']}}
+]);
+
+is($current_copy->call_number->record.'', '101', 
+    'Metarecord hold targeted after removing holdable_format restriction');
+
+# Return the hold and bib records to their original metarecord state 
+# for re-test-ability.
+$e->xact_begin;
+$hold->holdable_formats('{"0":[{"_attr":"mr_hold_format","_val":"score"}]}');
+$e->update_action_hold_request($hold) or die $e->die_event;
+$mrmap_101->metarecord($orig_101_mr);
+$mrmap_42->metarecord(42);
+$e->update_metabib_metarecord_source_map($mrmap_101) or die $e->die_event;
+$e->update_metabib_metarecord_source_map($mrmap_42) or die $e->die_event;
+$e->xact_commit;
+
+

commit 3203abfbb73465d3a2cd1082eef0563b1a700d1c
Author: Bill Erickson <berickxx at gmail.com>
Date:   Wed Oct 5 12:59:00 2016 -0400

    LP#1596595 Adds sample metarecord hold to concerto
    
    Adds a holdable_formats option to concerto's populate_hold() function.
    Inserts one metarecord hold for testing.
    
    Signed-off-by: Bill Erickson <berickxx at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/tests/datasets/sql/env_create.sql b/Open-ILS/tests/datasets/sql/env_create.sql
index ad5ba85..07dae6d 100644
--- a/Open-ILS/tests/datasets/sql/env_create.sql
+++ b/Open-ILS/tests/datasets/sql/env_create.sql
@@ -186,12 +186,13 @@ CREATE FUNCTION evergreen.populate_hold (
     requestor INTEGER,
     pickup_lib INTEGER,
     frozen BOOLEAN,
-    thawdate TIMESTAMP WITH TIME ZONE
+    thawdate TIMESTAMP WITH TIME ZONE,
+    holdable_formats TEXT DEFAULT NULL
 ) RETURNS void AS $$
 BEGIN
     INSERT INTO action.hold_request (
         requestor, hold_type, target, usr, pickup_lib, 
-            request_lib, selection_ou, frozen, thaw_date)
+            request_lib, selection_ou, frozen, thaw_date, holdable_formats)
     VALUES (
         requestor,
         hold_type,
@@ -201,7 +202,8 @@ BEGIN
         pickup_lib,
         pickup_lib,
         frozen,
-        thawdate
+        thawdate,
+        holdable_formats
     );
 
     -- Create hold notes for staff-placed holds: 1 public, 1 private
diff --git a/Open-ILS/tests/datasets/sql/env_destroy.sql b/Open-ILS/tests/datasets/sql/env_destroy.sql
index 9b51ab7..9f69865 100644
--- a/Open-ILS/tests/datasets/sql/env_destroy.sql
+++ b/Open-ILS/tests/datasets/sql/env_destroy.sql
@@ -11,5 +11,5 @@ DROP FUNCTION evergreen.next_bib (BIGINT);
 DROP FUNCTION evergreen.populate_circ 
     (INTEGER, INTEGER, BIGINT, INTEGER, TEXT, TEXT, TEXT, BOOLEAN);
 DROP FUNCTION evergreen.populate_hold 
-    (TEXT, BIGINT, INTEGER, INTEGER, INTEGER, BOOLEAN, TIMESTAMP WITH TIME ZONE);
+    (TEXT, BIGINT, INTEGER, INTEGER, INTEGER, BOOLEAN, TIMESTAMP WITH TIME ZONE, TEXT);
 
diff --git a/Open-ILS/tests/datasets/sql/transactions.sql b/Open-ILS/tests/datasets/sql/transactions.sql
index e95b0d6..cb9a73c 100644
--- a/Open-ILS/tests/datasets/sql/transactions.sql
+++ b/Open-ILS/tests/datasets/sql/transactions.sql
@@ -136,6 +136,14 @@ BEGIN
 
         END LOOP;                                                                   
     END LOOP;                                                                   
+
+    -- finally, add a metarecord hold w/ a holdable_format
+    -- for patron id=2 (home_ou=9)
+    PERFORM evergreen.populate_hold(
+        'M', 42, 2, 2, 9, FALSE, NULL, 
+        '{"0":[{"_attr":"mr_hold_format","_val":"score"}]}'
+    );
+
 END $$;
 
 

commit 75625c2f8866b3890c76defa4f7d9e74182b2fdd
Author: Bill Erickson <berickxx at gmail.com>
Date:   Tue Jun 7 17:32:14 2016 -0400

    LP#1596595 Hold targeter refactoring and optimization.
    
    * 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 at gmail.com>
    Signed-off-by: Chris Sharp <csharp at georgialibraries.org>
    Signed-off-by: Kathy Lussier <klussier at masslnc.org>

diff --git a/Open-ILS/examples/opensrf.xml.example b/Open-ILS/examples/opensrf.xml.example
index 87db4cc..167611a 100644
--- a/Open-ILS/examples/opensrf.xml.example
+++ b/Open-ILS/examples/opensrf.xml.example
@@ -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>
diff --git a/Open-ILS/src/Makefile.am b/Open-ILS/src/Makefile.am
index 00740f3..09a6439 100644
--- a/Open-ILS/src/Makefile.am
+++ b/Open-ILS/src/Makefile.am
@@ -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 \
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
index ad6fff2..7dd3611 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Circulate.pm
@@ -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;
 }
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
index 52f0893..9f5f42c 100644
--- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
@@ -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
index 0000000..037f230
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/HoldTargeter.pm
@@ -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
index 0000000..2a87180
--- /dev/null
+++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
@@ -0,0 +1,1269 @@
+package OpenILS::Utils::HoldTargeter;
+# ---------------------------------------------------------------
+# Copyright (C) 2016 King County Library System
+# Author: Bill Erickson <berickxx at 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);
+}
+
+
+
diff --git a/Open-ILS/src/sql/Pg/090.schema.action.sql b/Open-ILS/src/sql/Pg/090.schema.action.sql
index 923ba4e4..5832034 100644
--- a/Open-ILS/src/sql/Pg/090.schema.action.sql
+++ b/Open-ILS/src/sql/Pg/090.schema.action.sql
@@ -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,
diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
index 0b6b731..0440c6e 100644
--- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql
+++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql
@@ -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
index 0000000..ba584f0
--- /dev/null
+++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.hold_targeter.sql
@@ -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
index 0000000..7f342ef
--- /dev/null
+++ b/Open-ILS/src/support-scripts/hold_targeter_v2.pl
@@ -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;
+

-----------------------------------------------------------------------

Summary of changes:
 Open-ILS/examples/opensrf.xml.example              |   22 +
 Open-ILS/src/Makefile.am                           |    1 +
 .../perlmods/lib/OpenILS/Application/AppUtils.pm   |   38 +
 .../lib/OpenILS/Application/Circ/Circulate.pm      |   10 +-
 .../perlmods/lib/OpenILS/Application/Circ/Holds.pm |   23 +-
 .../lib/OpenILS/Application/HoldTargeter.pm        |   76 ++
 .../src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm | 1335 ++++++++++++++++++++
 Open-ILS/src/perlmods/live_t/20-hold-targeter.t    |  157 +++
 .../src/perlmods/live_t/21-batch-org-settings.t    |   43 +
 Open-ILS/src/sql/Pg/002.schema.config.sql          |    2 +-
 Open-ILS/src/sql/Pg/020.schema.functions.sql       |   20 +
 Open-ILS/src/sql/Pg/090.schema.action.sql          |    7 +
 Open-ILS/src/sql/Pg/950.data.seed-values.sql       |   13 +
 .../sql/Pg/upgrade/1019.schema.hold_targeter.sql   |   27 +
 .../upgrade/1020.schema.batch_settings_by_org.sql  |   26 +
 Open-ILS/src/support-scripts/hold_targeter_v2.pl   |  191 +++
 Open-ILS/tests/datasets/sql/env_create.sql         |    8 +-
 Open-ILS/tests/datasets/sql/env_destroy.sql        |    2 +-
 Open-ILS/tests/datasets/sql/transactions.sql       |    8 +
 .../Administration/hold-targeter.adoc              |   91 ++
 20 files changed, 2082 insertions(+), 18 deletions(-)
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Application/HoldTargeter.pm
 create mode 100644 Open-ILS/src/perlmods/lib/OpenILS/Utils/HoldTargeter.pm
 create mode 100644 Open-ILS/src/perlmods/live_t/20-hold-targeter.t
 create mode 100644 Open-ILS/src/perlmods/live_t/21-batch-org-settings.t
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1019.schema.hold_targeter.sql
 create mode 100644 Open-ILS/src/sql/Pg/upgrade/1020.schema.batch_settings_by_org.sql
 create mode 100755 Open-ILS/src/support-scripts/hold_targeter_v2.pl
 create mode 100644 docs/RELEASE_NOTES_NEXT/Administration/hold-targeter.adoc


hooks/post-receive
-- 
Evergreen ILS


More information about the open-ils-commits mailing list