evergreen/common/
targeter.rs

1use crate as eg;
2use eg::common::holds;
3use eg::common::settings::Settings;
4use eg::common::trigger;
5use eg::constants as C;
6use eg::date;
7use eg::{Editor, EgError, EgResult, EgValue};
8use rand;
9use rand::seq::SliceRandom;
10use std::collections::{HashMap, HashSet};
11use std::fmt;
12
13const PRECACHE_ORG_SETTINGS: &[&str] = &[
14    "circ.pickup_hold_stalling.hard",
15    "circ.holds.max_org_unit_target_loops",
16    "circ.holds.org_unit_target_weight",
17    "circ.holds.recall_threshold",
18];
19
20/// Slimmed down copy.
21#[derive(Debug)]
22pub struct PotentialCopy {
23    id: i64,
24    status: i64,
25    circ_lib: i64,
26    proximity: i64,
27    already_targeted: bool,
28}
29
30/// Tracks info for a single hold target run.
31///
32/// Some of these values should in theory be Options instesad of bare
33/// i64's, but testing for "0" works just as well and requires (overall,
34/// I believe) slightly less overhead.
35#[derive(Debug)]
36pub struct HoldTargetContext {
37    /// Did we successfully target our hold?
38    success: bool,
39
40    /// Hold ID
41    hold_id: i64,
42
43    hold: EgValue,
44
45    pickup_lib: i64,
46
47    /// Targeted copy ID.
48    ///
49    /// If we have a target, we succeeded.
50    target: i64,
51
52    /// Caller is specifically interested in this copy.
53    find_copy: i64,
54
55    /// Previous copy.
56    previous_copy_id: i64,
57
58    /// Previous copy that we know to be potentially targetable.
59    valid_previous_copy: Option<PotentialCopy>,
60
61    /// Lets the caller know we found the copy they were intersted in.
62    found_copy: bool,
63
64    /// Number of potentially targetable copies
65    eligible_copy_count: usize,
66
67    copies: Vec<PotentialCopy>,
68
69    // Final set of potential copies, including those that may not be
70    // currently targetable, that may be eligible for recall processing.
71    recall_copies: Vec<PotentialCopy>,
72
73    // Copies that are targeted, but could contribute to pickup lib
74    // hard (foreign) stalling.  These are Available-status copies.
75    otherwise_targeted_copies: Vec<PotentialCopy>,
76
77    /// Maps proximities to the weighted list of copy IDs.
78    weighted_prox_map: HashMap<i64, Vec<i64>>,
79}
80
81impl HoldTargetContext {
82    fn new(hold_id: i64, hold: EgValue) -> HoldTargetContext {
83        // Required, numeric value.
84        let pickup_lib = hold["pickup_lib"].int().expect("Hold Pickup Lib Required");
85
86        HoldTargetContext {
87            success: false,
88            hold_id,
89            hold,
90            pickup_lib,
91            copies: Vec::new(),
92            recall_copies: Vec::new(),
93            otherwise_targeted_copies: Vec::new(),
94            weighted_prox_map: HashMap::new(),
95            eligible_copy_count: 0,
96            target: 0,
97            find_copy: 0,
98            valid_previous_copy: None,
99            previous_copy_id: 0,
100            found_copy: false,
101        }
102    }
103
104    pub fn hold_id(&self) -> i64 {
105        self.hold_id
106    }
107    pub fn success(&self) -> bool {
108        self.success
109    }
110    pub fn found_copy(&self) -> bool {
111        self.found_copy
112    }
113    /// Returns a summary of this context as a JSON object.
114    pub fn to_json(&self) -> EgValue {
115        eg::hash! {
116            "hold": self.hold_id,
117            "success": self.success,
118            "target": self.target,
119            "old_target": self.previous_copy_id,
120            "found_copy": self.found_copy,
121            "eligible_copies": self.eligible_copy_count,
122        }
123    }
124}
125
126/// Targets a batch of holds.
127pub struct HoldTargeter<'a> {
128    editor: &'a mut Editor,
129
130    settings: Settings,
131
132    /// Hold in process -- mainly for logging.
133    hold_id: i64,
134
135    retarget_time: Option<String>,
136    retarget_interval: Option<String>,
137    soft_retarget_interval: Option<String>,
138    soft_retarget_time: Option<String>,
139    next_check_interval: Option<String>,
140
141    /// IDs of org units closed both now and at the next target time.
142    closed_orgs: Vec<i64>,
143
144    /// Copy statuses that are hopeless prone.
145    hopeless_prone_statuses: Vec<i64>,
146
147    /// Number of parallel slots; 0 means we are not running in parallel.
148    parallel_count: u8,
149
150    /// Which parallel slot do we occupy; 0 is none.
151    parallel_slot: u8,
152
153    /// Target holds newest first by request date.
154    newest_first: bool,
155
156    /// If true the targeter will NOT make any begin or commit
157    /// calls to its editor, assuming the caller will manage that.
158    ///
159    /// This is useful for cases where targeting a hold is part
160    /// of a larger transaction of changes.
161    ///
162    /// This should only be used when targeting a single hold
163    /// since each hold requires its own transaction to avoid deadlocks.
164    /// Alternatively, the caller should be prepared to begin/commit
165    /// before/after each call to target_hold().
166    transaction_manged_externally: bool,
167
168    thread_rng: rand::rngs::ThreadRng,
169}
170
171impl fmt::Display for HoldTargeter<'_> {
172    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
173        write!(f, "targeter: [hold={}]", self.hold_id)
174    }
175}
176
177impl<'a> HoldTargeter<'a> {
178    pub fn new(editor: &'a mut Editor) -> HoldTargeter<'a> {
179        let settings = Settings::new(editor);
180
181        HoldTargeter {
182            editor,
183            settings,
184            hold_id: 0,
185            retarget_time: None,
186            retarget_interval: None,
187            soft_retarget_interval: None,
188            soft_retarget_time: None,
189            next_check_interval: None,
190            parallel_count: 0,
191            parallel_slot: 0,
192            newest_first: false,
193            closed_orgs: Vec::new(),
194            hopeless_prone_statuses: Vec::new(),
195            transaction_manged_externally: false,
196            thread_rng: rand::thread_rng(),
197        }
198    }
199
200    /// Set this to true if the targeter should avoid making any
201    /// transaction begin / commit calls.
202    ///
203    /// The transaction may still be rolled back in cases where an action
204    /// failed, thus killing the transaction anyway.
205    ///
206    /// This is useful if the caller wants to target a hold within an
207    /// existing transaction.
208    pub fn set_transaction_manged_externally(&mut self, val: bool) {
209        self.transaction_manged_externally = val;
210    }
211
212    pub fn editor(&mut self) -> &mut Editor {
213        self.editor
214    }
215
216    pub fn set_parallel_count(&mut self, count: u8) {
217        self.parallel_count = count;
218    }
219
220    pub fn set_parallel_slot(&mut self, slot: u8) {
221        self.parallel_slot = slot;
222    }
223
224    pub fn set_retarget_interval(&mut self, intvl: &str) {
225        self.retarget_interval = Some(intvl.to_string());
226    }
227
228    pub fn set_soft_retarget_interval(&mut self, intvl: &str) {
229        self.soft_retarget_interval = Some(intvl.to_string());
230    }
231
232    pub fn set_next_check_interval(&mut self, intvl: &str) {
233        self.next_check_interval = Some(intvl.to_string());
234    }
235
236    pub fn init(&mut self) -> EgResult<()> {
237        let retarget_intvl_bind;
238        let retarget_intvl = if let Some(intvl) = self.retarget_interval.as_ref() {
239            intvl
240        } else {
241            let query = eg::hash! {
242                "name": "circ.holds.retarget_interval",
243                "enabled": "t"
244            };
245
246            if let Some(intvl) = self.editor().search("cgf", query)?.first() {
247                retarget_intvl_bind = intvl["value"].to_string();
248                retarget_intvl_bind.as_ref().unwrap()
249            } else {
250                // If all else fails, use a one day retarget interval.
251                "24 h"
252            }
253        };
254
255        log::info!("{self} using retarget interval: {retarget_intvl}");
256
257        let retarget_date = date::subtract_interval(date::now(), retarget_intvl)?;
258        let rt = date::to_iso(&retarget_date);
259
260        log::info!("{self} using retarget time: {rt}");
261
262        self.retarget_time = Some(rt);
263
264        if let Some(sri) = self.soft_retarget_interval.as_ref() {
265            let rt_date = date::subtract_interval(date::now(), sri)?;
266            let srt = date::to_iso(&rt_date);
267
268            log::info!("{self} using soft retarget time: {srt}");
269
270            self.soft_retarget_time = Some(srt);
271        }
272
273        // Holds targeted in the current targeter instance
274        // won't be retargeted until the next check date.  If a
275        // next_check_interval is provided it overrides the
276        // retarget_interval.
277        let next_check_intvl = self
278            .next_check_interval
279            .as_deref()
280            .unwrap_or(retarget_intvl);
281
282        let next_check_date = date::add_interval(date::now(), next_check_intvl)?;
283        let next_check_time = date::to_iso(&next_check_date);
284
285        log::info!("{self} next check time {next_check_time}");
286
287        // An org unit is considered closed for retargeting purposes
288        // if it's closed both now and at the next re-target date.
289        let query = eg::hash! {
290            "-and": [{
291                "close_start": {"<=": "now"},
292                "close_end": {">=": "now"}
293            }, {
294                "close_start": {"<=": next_check_time.as_str()},
295                "close_end": {">=": next_check_time.as_str()}
296            }]
297        };
298
299        let closed_orgs = self.editor().search("aoucd", query)?;
300
301        for co in closed_orgs {
302            self.closed_orgs.push(co["org_unit"].int()?);
303        }
304
305        for stat in self
306            .editor()
307            .search("ccs", eg::hash! {"hopeless_prone":"t"})?
308        {
309            self.hopeless_prone_statuses.push(stat["id"].int()?);
310        }
311
312        Ok(())
313    }
314
315    /// Find holds that need to be processed.
316    ///
317    /// When targeting a known hold ID, this step can be skipped.
318    pub fn find_holds_to_target(&mut self) -> EgResult<Vec<i64>> {
319        let mut query = eg::hash! {
320            "select": {"ahr": ["id"]},
321            "from": "ahr",
322            "where": {
323                "capture_time": eg::NULL,
324                "fulfillment_time": eg::NULL,
325                "cancel_time": eg::NULL,
326                "frozen": "f"
327            },
328            "order_by": [
329                {"class": "ahr", "field": "selection_depth", "direction": "DESC"},
330                {"class": "ahr", "field": "request_time"},
331                {"class": "ahr", "field": "prev_check_time"}
332            ]
333        };
334
335        // Target holds that have no prev_check_time or those whose
336        // re-target time has come.  If a soft_retarget_time is
337        // specified, that acts as the boundary.  Otherwise, the
338        // retarget_time is used.
339        let start_time = if let Some(t) = self.soft_retarget_time.as_ref() {
340            t.as_str()
341        } else {
342            self.retarget_time.as_ref().unwrap().as_str()
343        };
344
345        query["where"]["-or"] = eg::array! [
346            {"prev_check_time": eg::NULL},
347            {"prev_check_time": {"<=": start_time}},
348        ];
349
350        let parallel = self.parallel_count;
351
352        // The Perl code checks parallel > 0, but a parallel value of 1
353        // is also, by definition, non-parallel, so we can skip the
354        // theatrics below for values of <= 1.
355        if parallel > 1 {
356            // In parallel mode, we need to also grab the metarecord for each hold.
357
358            query["from"] = eg::hash! {
359                "ahr": {
360                    "rhrr": {
361                        "fkey": "id",
362                        "field": "id",
363                        "join": {
364                            "mmrsm": {
365                                "field": "source",
366                                "fkey": "bib_record"
367                            }
368                        }
369                    }
370                }
371            };
372
373            // In parallel mode, only process holds within the current
374            // process whose metarecord ID modulo the parallel targeter
375            // count matches our paralell targeting slot.  This ensures
376            // that no 2 processes will be operating on the same
377            // potential copy sets.
378            //
379            // E.g. Running 5 parallel and we are slot 3 (0-based slot
380            // 2) of 5, process holds whose metarecord ID's are 2, 7,
381            // 12, 17, ... WHERE MOD(mmrsm.id, 5) = 2
382
383            // Slots are 1-based at the API level, but 0-based for modulo.
384            let slot = self.parallel_slot - 1;
385
386            query["where"]["+mmrsm"] = eg::hash! {
387                "metarecord": {
388                    "=": {
389                        "transform": "mod",
390                        "value": slot,
391                        "params": [parallel]
392                    }
393                }
394            };
395        }
396
397        // Newest-first sorting cares only about hold create_time.
398        if self.newest_first {
399            query["order_by"] = eg::array! [{
400                "class": "ahr",
401                "field": "request_time",
402                "direction": "DESC"
403            }];
404        }
405
406        // NOTE The perl code runs this query in substream mode.
407        // At time of writing, the Rust editor has no substream mode.
408        // It seems far less critical for Redis, but can be added if needed.
409        let holds = self.editor().json_query(query)?;
410
411        log::info!("{self} found {} holds to target", holds.len());
412
413        Ok(holds.iter().map(|h| h["id"].int_required()).collect())
414    }
415
416    pub fn commit(&mut self) -> EgResult<()> {
417        if !self.transaction_manged_externally {
418            // Use commit() here to do a commit+disconnect from the cstore
419            // backend so the backends have a chance to cycle on large
420            // data sets.
421            self.editor().commit()?;
422        }
423
424        Ok(())
425    }
426
427    /// Update our in-process hold with the provided key/value pairs.
428    ///
429    /// Refresh our copy of the hold once updated to pick up DB-generated
430    /// values (dates, etc.).
431    fn update_hold(
432        &mut self,
433        context: &mut HoldTargetContext,
434        mut values: EgValue,
435    ) -> EgResult<()> {
436        for (k, v) in values.entries_mut() {
437            if k == "id" {
438                continue;
439            }
440            context.hold[k] = v.take();
441        }
442
443        self.editor().update(context.hold.clone())?;
444
445        // this hold id must exist.
446        context.hold = self
447            .editor()
448            .retrieve("ahr", context.hold_id)?
449            .ok_or("Cannot find hold")?;
450
451        Ok(())
452    }
453
454    /// Return false if the hold is not eligible for targeting (frozen,
455    /// canceled, etc.)
456    fn hold_is_targetable(&mut self, context: &HoldTargetContext) -> bool {
457        let hold = &context.hold;
458
459        if hold["capture_time"].is_null()
460            && hold["cancel_time"].is_null()
461            && hold["fulfillment_time"].is_null()
462            && !hold["frozen"].boolish()
463        {
464            return true;
465        }
466
467        log::info!("{self} hold is not targetable");
468
469        false
470    }
471
472    /// Cancel expired holds and kick off the A/T no-target event.
473    ///
474    /// Returns true if the hold was marked as expired, indicating no
475    /// further targeting is needed.
476    fn hold_is_expired(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
477        if let Some(etime) = context.hold["expire_time"].as_str() {
478            let ex_time = date::parse_datetime(etime)?;
479
480            if ex_time > date::now() {
481                // Hold has not yet expired.
482                return Ok(false);
483            }
484        } else {
485            // Hold has no expire time.
486            return Ok(false);
487        }
488
489        // -- Hold is expired --
490        let values = eg::hash! {
491            "cancel_time": "now",
492            "cancel_cause": 1, // un-targeted expiration
493        };
494
495        self.update_hold(context, values)?;
496
497        // Create events that will be fired/processed later.
498        trigger::create_events_for_object(
499            self.editor(),
500            "hold_request.cancel.expire_no_target",
501            &context.hold,
502            context.pickup_lib,
503            None,
504            None,
505            false,
506        )?;
507
508        Ok(true)
509    }
510
511    /// Find potential copies for mapping/targeting and add them to
512    /// the copies list on our context.
513    fn get_hold_copies(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
514        let hold = &context.hold;
515
516        let hold_target = hold["target"].int()?;
517        let hold_type = hold["hold_type"].as_str().unwrap(); // required.
518        let org_unit = hold["selection_ou"].int()?;
519        let org_depth = hold["selection_depth"].as_int().unwrap_or(0); // not required
520
521        let mut query = eg::hash! {
522            "select": {
523                "acp": ["id", "status", "circ_lib"],
524                "ahr": ["current_copy"]
525            },
526            "from": {
527                "acp": {
528                    // Tag copies that are in use by other holds so we don't
529                    // try to target them for our hold.
530                    "ahr": {
531                        "type": "left",
532                        "fkey": "id", // acp.id
533                        "field": "current_copy",
534                        "filter": {
535                            "fulfillment_time": eg::NULL,
536                            "cancel_time": eg::NULL,
537                            "id": {"!=": context.hold_id},
538                        }
539                    }
540                }
541            },
542            "where": {
543                "+acp": {
544                    "deleted": "f",
545                    "circ_lib": {
546                        "in": {
547                            "select": {
548                                "aou": [{
549                                    "transform": "actor.org_unit_descendants",
550                                    "column": "id",
551                                    "result_field": "id",
552                                    "params": [org_depth],
553                                }],
554                                },
555                            "from": "aou",
556                            "where": {"id": org_unit},
557                        }
558                    }
559                }
560            }
561        };
562
563        if hold_type != "R" && hold_type != "F" {
564            // Add the holdability filters to the copy query, unless
565            // we're processing a Recall or Force hold, which bypass most
566            // holdability checks.
567
568            query["from"]["acp"]["acpl"] = eg::hash! {
569                "field": "id",
570                "filter": {"holdable": "t", "deleted": "f"},
571                "fkey": "location",
572            };
573
574            query["from"]["acp"]["ccs"] = eg::hash! {
575                "field": "id",
576                "filter": {"holdable": "t"},
577                "fkey": "status",
578            };
579
580            query["where"]["+acp"]["holdable"] = EgValue::from("t");
581
582            if hold["mint_condition"].boolish() {
583                query["where"]["+acp"]["mint_condition"] = EgValue::from("t");
584            }
585        }
586
587        if hold_type != "C" && hold_type != "I" && hold_type != "P" {
588            // For volume and higher level holds, avoid targeting copies that
589            // act as instances of monograph parts.
590
591            query["from"]["acp"]["acpm"] = eg::hash! {
592                "type": "left",
593                "field": "target_copy",
594                "fkey": "id"
595            };
596
597            query["where"]["+acpm"]["id"] = eg::NULL;
598        }
599
600        // Add the target filters
601        if hold_type == "C" || hold_type == "R" || hold_type == "F" {
602            query["where"]["+acp"]["id"] = EgValue::from(hold_target);
603        } else if hold_type == "V" {
604            query["where"]["+acp"]["call_number"] = EgValue::from(hold_target);
605        } else if hold_type == "P" {
606            query["from"]["acp"]["acpm"] = eg::hash! {
607                "field" : "target_copy",
608                "fkey" : "id",
609                "filter": {"part": hold_target},
610            };
611        } else if hold_type == "I" {
612            query["from"]["acp"]["sitem"] = eg::hash! {
613                "field" : "unit",
614                "fkey" : "id",
615                "filter": {"issuance": hold_target},
616            };
617        } else if hold_type == "T" {
618            query["from"]["acp"]["acn"] = eg::hash! {
619                "field" : "id",
620                "fkey" : "call_number",
621                "join": {
622                    "bre": {
623                        "field" : "id",
624                        "filter": {"id": hold_target},
625                        "fkey"  : "record"
626                    }
627                }
628            };
629        } else {
630            // Metarecord hold
631
632            query["from"]["acp"]["acn"] = eg::hash! {
633                "field": "id",
634                "fkey": "call_number",
635                "join": {
636                    "bre": {
637                        "field": "id",
638                        "fkey": "record",
639                        "join": {
640                            "mmrsm": {
641                                "field": "source",
642                                "fkey": "id",
643                                "filter": {"metarecord": hold_target},
644                            }
645                        }
646                    }
647                }
648            };
649
650            if let Some(formats) = hold["holdable_formats"].as_str() {
651                // Compile the JSON-encoded metarecord holdable formats
652                // to an Intarray query_int string.
653
654                let query_ints = self.editor().json_query(eg::hash! {
655                    "from": ["metabib.compile_composite_attr", formats]
656                })?;
657
658                if let Some(query_int) = query_ints.first() {
659                    // Only pull potential copies from records that satisfy
660                    // the holdable formats query.
661                    if let Some(qint) = query_int["metabib.compile_composite_attr"].as_str() {
662                        query["from"]["acp"]["acn"]["join"]["bre"]["join"]["mravl"] = eg::hash! {
663                            "field": "source",
664                            "fkey": "id",
665                            "filter": {"vlist": {"@@": qint}}
666                        }
667                    }
668                }
669            }
670        }
671
672        let mut found_copy = false;
673        let mut circ_libs: HashSet<i64> = HashSet::new();
674        context.copies = self
675            .editor()
676            .json_query(query)?
677            .iter()
678            .map(|c| {
679                // While we're looping, see if we found the copy the
680                // caller was interested in.
681                let id = c["id"].int_required();
682                if id == context.find_copy {
683                    found_copy = true;
684                }
685
686                let copy = PotentialCopy {
687                    id,
688                    status: c["status"].int_required(),
689                    circ_lib: c["circ_lib"].int_required(),
690                    proximity: -1,
691                    already_targeted: !c["current_copy"].is_null(),
692                };
693
694                circ_libs.insert(copy.circ_lib);
695
696                copy
697            })
698            .collect();
699
700        context.eligible_copy_count = context.copies.len();
701        context.found_copy = found_copy;
702
703        log::info!("{self} {} potential copies", context.eligible_copy_count);
704
705        // Pre-cache some org unit settings
706        for lib in circ_libs.iter() {
707            log::info!("{self} pre-caching org settings for {lib}");
708            self.settings
709                .fetch_values_for_org(*lib, PRECACHE_ORG_SETTINGS)?;
710        }
711
712        Ok(())
713    }
714
715    /// Tell the DB to update the list of potential copies for our hold
716    /// based on the copies we just found.
717    fn update_copy_maps(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
718        log::info!("{self} creating {} hold copy maps", context.copies.len());
719
720        let ints = context
721            .copies
722            .iter()
723            .map(|c| format!("{}", c.id))
724            .collect::<Vec<String>>()
725            .join(",");
726
727        // "{1,2,3}"
728        let ints = format!("{{{ints}}}");
729
730        let query = eg::hash! {
731            "from": [
732                "action.hold_request_regen_copy_maps",
733                context.hold_id,
734                ints
735            ]
736        };
737
738        self.editor().json_query(query).map(|_| ())
739    }
740
741    /// Set the hopeless date on a hold when needed.
742    ///
743    /// If no copies were found and hopeless date is not set,
744    /// then set it. Otherwise, all found copies have a hopeless
745    /// status, set the hold as hopeless.  Otherwise, clear the
746    /// date if set.
747    fn handle_hopeless_date(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
748        let marked_hopeless = !context.hold["hopeless_date"].is_null();
749
750        if context.copies.is_empty() && !marked_hopeless {
751            log::info!("{self} Marking hold as hopeless");
752            return self.update_hold(context, eg::hash! {"hopeless_date": "now"});
753        }
754
755        // Hope left in any of the statuses?
756        let we_have_hope = context
757            .copies
758            .iter()
759            .any(|c| !self.hopeless_prone_statuses.contains(&c.status));
760
761        if marked_hopeless {
762            if we_have_hope {
763                log::info!("{self} Removing hopeless date");
764                return self.update_hold(context, eg::hash! {"hopeless_date": eg::NULL});
765            }
766        } else if !we_have_hope {
767            log::info!("{self} Marking hold as hopeless");
768            return self.update_hold(context, eg::hash! {"hopeless_date": "now"});
769        }
770
771        Ok(())
772    }
773
774    /// If the hold has no usable copies, commit the transaction and return
775    /// true (i.e. stop targeting), false otherwise.
776    fn hold_has_no_copies(
777        &mut self,
778        context: &mut HoldTargetContext,
779        force: bool,
780        process_recalls: bool,
781    ) -> EgResult<bool> {
782        if !force {
783            // If 'force' is set, the caller is saying that all copies have
784            // failed.  Otherwise, see if we have any copies left to inspect.
785            if !context.copies.is_empty() || context.valid_previous_copy.is_some() {
786                return Ok(false);
787            }
788        }
789
790        // At this point, all copies have been inspected and none
791        // have yielded a targetable item.
792
793        if process_recalls {
794            // Regardless of whether we find a circulation to recall,
795            // we want to clear the hold below.
796            self.process_recalls(context)?;
797        }
798
799        let values = eg::hash! {
800            "current_copy": eg::NULL,
801            "prev_check_time": "now"
802        };
803
804        self.update_hold(context, values)?;
805
806        log::info!("{self} hold officially has no targetable copies");
807
808        Ok(true)
809    }
810
811    /// Attempts to recall a circulation so its item may be used to
812    /// fill the hold once returned.
813    ///
814    /// Note that recalling (or not) a circ has no direct impact on the hold.
815    fn process_recalls(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
816        if context.recall_copies.is_empty() {
817            return Ok(());
818        }
819
820        let recall_threshold = self
821            .settings
822            .get_value_at_org("circ.holds.recall_threshold", context.pickup_lib)?;
823
824        let recall_threshold = match recall_threshold.to_string() {
825            Some(t) => t,
826            None => return Ok(()),
827        };
828
829        let return_interval = self
830            .settings
831            .get_value_at_org("circ.holds.recall_return_interval", context.pickup_lib)?;
832
833        let return_interval = match return_interval.to_string() {
834            Some(t) => t,
835            None => return Ok(()),
836        };
837
838        let copy_ids = context
839            .recall_copies
840            .iter()
841            .map(|c| c.id)
842            .collect::<Vec<i64>>();
843
844        // See if we have a circulation linked to our recall copies
845        // that we can recall.
846        let query = eg::hash! {
847            "target_copy": copy_ids,
848            "checkin_time": eg::NULL,
849            "duration": {">": recall_threshold.as_str()}
850        };
851
852        let ops = eg::hash! {
853            "order_by": [{"class": "circ", "field": "due_date"}],
854            "limit": 1
855        };
856
857        let mut circs = self.editor().search_with_ops("circ", query, ops)?;
858
859        let mut circ = match circs.pop() {
860            Some(c) => c,
861            // Tried our best to recall a circ but could not find one.
862            None => {
863                log::info!("{self} no circulations to recall");
864                return Ok(());
865            }
866        };
867
868        log::info!("{self} recalling circ {}", circ["id"]);
869
870        let old_due_date = date::parse_datetime(circ["due_date"].as_str().unwrap())?;
871        let xact_start_date = date::parse_datetime(circ["xact_start"].as_str().unwrap())?;
872
873        let thresh_date = date::add_interval(xact_start_date, &recall_threshold)?;
874        let mut return_date = date::add_interval(date::now(), &return_interval)?;
875
876        // Give the user a new due date of either a full recall threshold,
877        // or the return interval, whichever is further in the future.
878        if thresh_date > return_date {
879            return_date = thresh_date;
880        }
881
882        // ... but avoid exceeding the old due date.
883        if return_date > old_due_date {
884            return_date = old_due_date;
885        }
886
887        circ["due_date"] = date::to_iso(&return_date).into();
888        circ["renewal_remaining"] = 0.into();
889
890        let mut fine_rules = self
891            .settings
892            .get_value_at_org("circ.holds.recall_fine_rules", context.pickup_lib)?
893            .clone();
894
895        log::debug!("{self} recall fine rules: {}", fine_rules);
896
897        // fine_rules => [fine, interval, max];
898        if fine_rules.is_array() && fine_rules.len() == 3 {
899            circ["max_fine"] = fine_rules.pop();
900            circ["fine_interval"] = fine_rules.pop();
901            circ["recurring_fine"] = fine_rules.pop();
902        }
903
904        // Create events that will be fired/processed later.  Run this
905        // before update(circ) so the editor call can consume the circ.
906        // Trigger gets its values for 'target' from the target value
907        // provided, so it's OK to create the trigger events before the
908        // circ is updated in the database.
909        trigger::create_events_for_object(
910            self.editor(),
911            "circ.recall.target",
912            &circ,
913            circ["circ_lib"].int()?,
914            None,
915            None,
916            false,
917        )?;
918
919        self.editor().update(circ)?;
920
921        Ok(())
922    }
923
924    /// Trim the copy list to those that are currently targetable and
925    /// move checked out items to the recall list.
926    fn filter_copies_by_status_and_targeted(&self, context: &mut HoldTargetContext) {
927        let mut targetable = Vec::new();
928
929        while let Some(copy) = context.copies.pop() {
930            if copy.status == C::COPY_STATUS_CHECKED_OUT {
931                context.recall_copies.push(copy);
932                continue;
933            }
934
935            if copy.already_targeted {
936                context.otherwise_targeted_copies.push(copy);
937                continue;
938            }
939
940            if copy.status == C::COPY_STATUS_AVAILABLE || copy.status == C::COPY_STATUS_RESHELVING {
941                targetable.push(copy);
942            }
943        }
944
945        log::info!(
946            "{self} potential copies checked out={}, otherwise targeted={}, available={}",
947            context.recall_copies.len(),
948            context.otherwise_targeted_copies.len(),
949            targetable.len()
950        );
951
952        context.copies = targetable;
953    }
954
955    /// Removes copies for consideration when they live at a closed org unit
956    /// and settings prevent targeting when closed.
957    fn filter_closed_date_copies(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
958        let mut targetable = Vec::new();
959
960        while let Some(copy) = context.copies.pop() {
961            if self.closed_orgs.contains(&copy.circ_lib) {
962                let setting = if copy.circ_lib == context.pickup_lib {
963                    "circ.holds.target_when_closed_if_at_pickup_lib"
964                } else {
965                    "circ.holds.target_when_closed"
966                };
967
968                let value = self.settings.get_value_at_org(setting, copy.circ_lib)?;
969
970                if value.boolish() {
971                    log::info!("{self} skipping copy at closed org unit {}", copy.circ_lib);
972                    continue;
973                }
974            }
975
976            targetable.push(copy);
977        }
978
979        context.copies = targetable;
980
981        Ok(())
982    }
983
984    /// Returns true if the on-DB permit test says this copy is permitted.
985    fn copy_is_permitted(
986        &mut self,
987        context: &mut HoldTargetContext,
988        copy_id: i64,
989    ) -> EgResult<bool> {
990        let result = holds::test_copy_for_hold(
991            self.editor(),
992            holds::CopyHoldParams {
993                patron_id: context.hold["usr"].int()?,
994                copy_id,
995                pickup_lib: context.pickup_lib,
996                request_lib: context.hold["request_lib"].int()?,
997                requestor: context.hold["requestor"].int()?,
998                is_retarget: true,
999            },
1000            None, // overrides
1001            true, // check_only
1002        )?;
1003
1004        if result.success() {
1005            log::info!("{self} copy {copy_id} is permitted");
1006            return Ok(true);
1007        }
1008
1009        // Copy is non-viable.  Remove it from our list.
1010        if let Some(pos) = context.copies.iter().position(|c| c.id == copy_id) {
1011            log::info!("{self} copy {copy_id} is not permitted");
1012            context.copies.remove(pos);
1013        }
1014
1015        Ok(false)
1016    }
1017
1018    /// Returns true if we have decided to retarget the existing copy.
1019    ///
1020    /// Otherwise, sets aside the previously targeted copy in case in
1021    /// may be of use later... and returns false.
1022    fn inspect_previous_target(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
1023        let prev_copy = match context.hold["current_copy"].as_int() {
1024            Some(c) => c,
1025            None => return Ok(false), // value was null
1026        };
1027
1028        context.previous_copy_id = prev_copy;
1029
1030        if !context.copies.iter().any(|c| c.id == prev_copy) {
1031            return Ok(false);
1032        }
1033
1034        let mut soft_retarget = false;
1035        if self.soft_retarget_time.is_some() {
1036            // A hold is soft-retarget-able if its prev_check_time is
1037            // later then the retarget_time, i.e. it sits between the
1038            // soft_retarget_time and the retarget_time.
1039
1040            if let Some(prev_check_time) = context.hold["prev_check_time"].as_str() {
1041                if let Some(retarget_time) = self.retarget_time.as_deref() {
1042                    soft_retarget = prev_check_time > retarget_time;
1043                }
1044            }
1045        }
1046
1047        let mut retain_prev = false;
1048        if soft_retarget {
1049            // In soft-retarget mode, exit early if the existing copy is valid.
1050            if self.copy_is_permitted(context, prev_copy)? {
1051                log::info!("{self} retaining previous copy in soft-retarget");
1052                return Ok(true);
1053            }
1054
1055            log::info!("{self} previous copy is no longer viable.  Retargeting");
1056        } else {
1057            // Previously targeted copy may yet be useful.
1058            retain_prev = true;
1059        }
1060
1061        // Remove the previous copy from the working set of potential
1062        // copies.  It will be revisited later if needed.
1063        if let Some(pos) = context.copies.iter().position(|c| c.id == prev_copy) {
1064            let copy = context.copies.remove(pos);
1065            if retain_prev {
1066                context.valid_previous_copy = Some(copy);
1067            }
1068        }
1069
1070        Ok(false)
1071    }
1072
1073    /// Store info in the database about the fact that this hold was
1074    /// not captured.
1075    fn log_unfulfilled_hold(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1076        if context.previous_copy_id == 0 {
1077            return Ok(());
1078        }
1079
1080        log::info!(
1081            "{self} logging unsuccessful capture of previous copy: {}",
1082            context.previous_copy_id
1083        );
1084
1085        let circ_lib = if let Some(copy) = context.valid_previous_copy.as_ref() {
1086            copy.circ_lib
1087        } else {
1088            // We don't have a handle on the previous copy to get its
1089            // circ lib.  Fetch it.
1090
1091            let copy = self
1092                .editor()
1093                .retrieve("acp", context.previous_copy_id)?
1094                .ok_or(format!("Cannot find copy {}", context.previous_copy_id))?;
1095
1096            copy["circ_lib"].int()?
1097        };
1098
1099        let mut unful = eg::hash! {
1100            "hold": self.hold_id,
1101            "circ_lib": circ_lib,
1102            "current_copy": context.previous_copy_id
1103        };
1104
1105        unful.bless("aufh")?;
1106        self.editor().create(unful)?;
1107
1108        Ok(())
1109    }
1110
1111    /// Set the 'target' value on the context to the first (and presumably
1112    /// only) copy in our list of valid copies if this is a Force or Recall
1113    /// hold, which bypass policy checks.
1114    fn attempt_force_recall_target(&self, context: &mut HoldTargetContext) {
1115        if let Some(ht) = context.hold["hold_type"].as_str() {
1116            if ht == "R" || ht == "F" {
1117                if let Some(c) = context.copies.first() {
1118                    context.target = c.id;
1119                    log::info!("{self} force/recall hold using copy {}", c.id);
1120                }
1121            }
1122        }
1123    }
1124
1125    /// Returns true if the hold was canceled while looking for a target
1126    /// (e.g. hits max target loops).
1127    /// Sets context.target if it can.
1128    fn attempt_to_find_copy(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
1129        if context.target > 0 {
1130            return Ok(false);
1131        }
1132
1133        let max_loops = self
1134            .settings
1135            .get_value_at_org("circ.holds.max_org_unit_target_loops", context.pickup_lib)?;
1136
1137        if let Some(max) = max_loops.as_int() {
1138            if let Some(copy_id) = self.target_by_org_loops(context, max)? {
1139                context.target = copy_id;
1140            }
1141        } else {
1142            // When not using target loops, targeting is based solely on
1143            // proximity and org unit target weight.
1144            self.compile_weighted_proximity_map(context)?;
1145
1146            if let Some(copy_id) = self.find_nearest_copy(context)? {
1147                context.target = copy_id;
1148            }
1149        }
1150
1151        Ok(!context.hold["cancel_time"].is_null())
1152    }
1153
1154    /// Returns the closest copy by proximity that is a confirmed valid
1155    /// targetable copy.
1156    fn find_nearest_copy(&mut self, context: &mut HoldTargetContext) -> EgResult<Option<i64>> {
1157        let inside_hard_stall = self.inside_hard_stall_interval(context)?;
1158        let mut have_local_copies = false;
1159
1160        // If we're still hard stallin', see if we have any local
1161        // copies in use.
1162        if inside_hard_stall {
1163            have_local_copies = context
1164                .otherwise_targeted_copies
1165                .iter()
1166                .any(|c| c.proximity <= 0);
1167        }
1168
1169        // Pick a copy at random from each tier of the proximity map,
1170        // starting at the lowest proximity and working up, until a
1171        // copy is found that is suitable for targeting.
1172        let mut sorted_proximities: Vec<i64> = context.weighted_prox_map.keys().copied().collect();
1173
1174        sorted_proximities.sort();
1175
1176        let mut already_tested_copies: HashSet<i64> = HashSet::new();
1177
1178        for prox in sorted_proximities {
1179            let copy_ids = match context.weighted_prox_map.get_mut(&prox) {
1180                Some(list) => list,
1181                None => continue, // Shouldn't happen
1182            };
1183
1184            if copy_ids.is_empty() {
1185                continue;
1186            }
1187
1188            if prox <= 0 {
1189                have_local_copies = true;
1190            }
1191
1192            if have_local_copies && inside_hard_stall && prox > 0 {
1193                // We have attempted to target all local (prox <= 0)
1194                // copies and come up with zilch.
1195                //
1196                // We're also still in the hard-stall interval and we
1197                // have local copies that could be targeted later.
1198                // There's nothing else we can do until the stall time
1199                // expires or a local copy becomes targetable on a
1200                // future targeting run.
1201                break;
1202            }
1203
1204            // Clone the list so we can modify at will and avoid a
1205            // parallell borrow on the context.
1206            let mut copy_ids = copy_ids.clone();
1207
1208            // Shuffle the weighted list for random selection.
1209            copy_ids.shuffle(&mut self.thread_rng);
1210
1211            for copy_id in copy_ids.iter() {
1212                if already_tested_copies.contains(copy_id) {
1213                    // No point in testing the same copy twice.
1214                    continue;
1215                }
1216
1217                if self.copy_is_permitted(context, *copy_id)? {
1218                    return Ok(Some(*copy_id));
1219                }
1220
1221                already_tested_copies.insert(*copy_id);
1222            }
1223        }
1224
1225        if have_local_copies && inside_hard_stall {
1226            // If we have local copies and we're still hard stallin',
1227            // we're no longer interested in non-local copies.  Clear
1228            // the valid_previous_copy if it's not local.
1229            if let Some(copy) = context.valid_previous_copy.as_ref() {
1230                if copy.proximity > 0 {
1231                    context.valid_previous_copy = None;
1232                }
1233            }
1234        }
1235
1236        Ok(None)
1237    }
1238
1239    fn inside_hard_stall_interval(&mut self, context: &mut HoldTargetContext) -> EgResult<bool> {
1240        let interval = self
1241            .settings
1242            .get_value_at_org("circ.pickup_hold_stalling.hard", context.pickup_lib)?;
1243
1244        let interval = match interval.as_str() {
1245            Some(s) => s,
1246            None => return Ok(false),
1247        };
1248
1249        // Required, string field
1250        let req_time = context.hold["request_time"].as_str().unwrap();
1251        let req_time = date::parse_datetime(req_time)?;
1252
1253        let hard_stall_time = date::add_interval(req_time, interval)?;
1254
1255        log::info!("{self} hard stall deadline is/was {hard_stall_time}");
1256
1257        let inside = hard_stall_time > date::now();
1258
1259        log::info!("{self} still within hard stall interval? {inside}");
1260
1261        Ok(inside)
1262    }
1263
1264    /// Find libs whose unfulfilled target count is less than the maximum
1265    /// configured loop count.  Target copies in order of their circ_lib's
1266    /// target count (starting at 0) and moving up.  Copies within each
1267    /// loop count group are weighted based on configured hold weight.  If
1268    /// no copies in a given group are targetable, move up to the next
1269    /// unfulfilled target level.  Keep doing this until all potential
1270    /// copies have been tried or max targets loops is exceeded.
1271    /// Returns a targetable copy if one is found, undef otherwise.
1272    fn target_by_org_loops(
1273        &mut self,
1274        context: &mut HoldTargetContext,
1275        max_loops: i64,
1276    ) -> EgResult<Option<i64>> {
1277        let query = eg::hash! {
1278            "select": {"aufhl": ["circ_lib", "count"]},
1279            "from": "aufhl",
1280            "where": {"hold": self.hold_id},
1281            "order_by": [{"class": "aufhl", "field": "count"}]
1282        };
1283
1284        let targeted_libs = self.editor().json_query(query)?;
1285
1286        // Highest per-lib target attempts
1287        let mut max_tried = 0;
1288        for lib in targeted_libs.iter() {
1289            let count = lib["count"].int()?;
1290            if count > max_tried {
1291                max_tried = count;
1292            }
1293        }
1294
1295        log::info!("{self} max lib attempts is {max_tried}");
1296        log::info!(
1297            "{self} {} libs have been targeted at least once",
1298            targeted_libs.len()
1299        );
1300
1301        // loop_iter represents per-lib target attemtps already made.
1302        // When loop_iter equals max loops, all libs with targetable copies
1303        // have been targeted the maximum number of times.  loop_iter starts
1304        // at 0 to pick up libs that have never been targeted.
1305        let mut loop_iter = 0;
1306
1307        while loop_iter < max_loops {
1308            loop_iter += 1;
1309
1310            // Ran out of copies to try before exceeding max target loops.
1311            // Nothing else to do here.
1312            if context.copies.is_empty() {
1313                return Ok(None);
1314            }
1315
1316            let (iter_copies, remaining_copies) =
1317                self.get_copies_at_loop_iter(context, &targeted_libs, loop_iter - 1);
1318
1319            if iter_copies.is_empty() {
1320                // None at this level.  Bump up a level.
1321                context.copies = remaining_copies;
1322                continue;
1323            }
1324
1325            context.copies = iter_copies;
1326
1327            // Update the proximity map to only include the copies
1328            // from this loop-depth iteration.
1329            self.compile_weighted_proximity_map(context)?;
1330
1331            if let Some(copy) = self.find_nearest_copy(context)? {
1332                // OK for context.copies to be partially cleared at this
1333                // point, because this copy we have found is known
1334                // to be permitted.  No more copy checks needed.
1335                return Ok(Some(copy));
1336            }
1337
1338            // No targetable copy at the current target leve.
1339            // Update our current copy set to the not-yet-tested copies.
1340            context.copies = remaining_copies;
1341        }
1342
1343        if max_tried >= max_loops {
1344            // At least one lib has been targeted max-loops times and zero
1345            // other copies are targetable.  All options have been exhausted.
1346            self.handle_exceeds_target_loops(context)?;
1347        }
1348
1349        Ok(None)
1350    }
1351
1352    /// Cancel the hold and fire the no-target A/T event creator.
1353    fn handle_exceeds_target_loops(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1354        let values = eg::hash! {
1355            "cancel_time": "now",
1356            "cancel_cause": 1, // un-targeted expiration
1357        };
1358
1359        self.update_hold(context, values)?;
1360
1361        trigger::create_events_for_object(
1362            self.editor(),
1363            "hold_request.cancel.expire_no_target",
1364            &context.hold,
1365            context.pickup_lib,
1366            None,
1367            None,
1368            false,
1369        )?;
1370
1371        Ok(())
1372    }
1373
1374    /// Returns a map of proximity values to arrays of copy hashes.
1375    /// The copy hash arrays are weighted consistent with the org unit hold
1376    /// target weight, meaning that a given copy may appear more than once
1377    /// in its proximity list.
1378    fn compile_weighted_proximity_map(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1379        // Collect copy proximity info (generated via DB trigger)
1380        // from our newly create copy maps.
1381
1382        let query = eg::hash! {
1383            "select": {"ahcm": ["target_copy", "proximity"]},
1384            "from": "ahcm",
1385            "where": {"hold": self.hold_id}
1386        };
1387
1388        let copy_maps = self.editor().json_query(query)?;
1389
1390        let mut flat_map: HashMap<i64, i64> = HashMap::new();
1391
1392        for map in copy_maps.iter() {
1393            let copy_id = map["target_copy"].int()?;
1394            let proximity = map["proximity"].int()?;
1395            flat_map.insert(copy_id, proximity);
1396        }
1397
1398        // The weight of a copy at a give proximity is a function
1399        // of how many times the copy ID appears in the list
1400        // at that proximity.
1401        let mut weighted: HashMap<i64, Vec<i64>> = HashMap::new();
1402        for copy in context.copies.iter_mut() {
1403            let prox = match flat_map.get(&copy.id) {
1404                Some(p) => *p,    // &i64
1405                None => continue, // should not happen
1406            };
1407
1408            copy.proximity = prox;
1409
1410            weighted.entry(prox).or_default();
1411
1412            let weight = self
1413                .settings
1414                .get_value_at_org("circ.holds.org_unit_target_weight", copy.circ_lib)?;
1415
1416            let weight = if weight.is_null() { 1 } else { weight.int()? };
1417
1418            if let Some(list) = weighted.get_mut(&prox) {
1419                for _ in 0..weight {
1420                    list.push(copy.id);
1421                }
1422            }
1423        }
1424
1425        // We need to grab the proximity for copies targeted by other
1426        // holds that belong to this pickup lib for hard-stalling tests
1427        // later. We'll just grab them all in case it's useful later.
1428        for copy in context.otherwise_targeted_copies.iter_mut() {
1429            if let Some(prox) = flat_map.get(&copy.id) {
1430                copy.proximity = *prox;
1431            }
1432        }
1433
1434        // We also need the proximity for the previous target.
1435        if let Some(copy) = context.valid_previous_copy.as_mut() {
1436            if let Some(prox) = flat_map.get(&copy.id) {
1437                copy.proximity = *prox;
1438            }
1439        }
1440
1441        context.weighted_prox_map = weighted;
1442
1443        Ok(())
1444    }
1445
1446    /// Returns 2 vecs.  The first is a list of copies whose circ lib's
1447    /// unfulfilled target count matches the provided loop_iter value.  The
1448    /// second list is all other copies, returned for convenience.
1449    ///
1450    /// NOTE this drains context.copies into the two arrays returned!
1451    fn get_copies_at_loop_iter(
1452        &self,
1453        context: &mut HoldTargetContext,
1454        targeted_libs: &[EgValue],
1455        loop_iter: i64,
1456    ) -> (Vec<PotentialCopy>, Vec<PotentialCopy>) {
1457        let mut iter_copies = Vec::new();
1458        let mut remaining_copies = Vec::new();
1459
1460        while let Some(copy) = context.copies.pop() {
1461            let match_found = if loop_iter == 0 {
1462                // Start with copies at circ libs that have never been targeted.
1463                !targeted_libs
1464                    .iter()
1465                    .any(|l| l["circ_lib"].int_required() == copy.circ_lib)
1466            } else {
1467                // Find copies at branches whose target count
1468                // matches the current (non-zero) loop depth.
1469                targeted_libs.iter().any(|l| {
1470                    l["circ_lib"].int_required() == copy.circ_lib
1471                        && l["count"].int_required() == loop_iter
1472                })
1473            };
1474
1475            if match_found {
1476                iter_copies.push(copy);
1477            } else {
1478                remaining_copies.push(copy);
1479            }
1480        }
1481
1482        log::info!(
1483            "{self} {} potential copies at max-loops iter level {loop_iter}. \
1484            {} remain to be tested at a higher loop iteration level",
1485            iter_copies.len(),
1486            remaining_copies.len()
1487        );
1488
1489        (iter_copies, remaining_copies)
1490    }
1491
1492    /// All we might have left is the copy this hold previously targeted.
1493    /// Grab it if we can.
1494    fn attempt_prev_copy_retarget(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1495        if context.target > 0 {
1496            return Ok(());
1497        }
1498
1499        if let Some(copy_id) = context.valid_previous_copy.as_ref().map(|c| c.id) {
1500            log::info!(
1501                "Attempting to retarget previously targeted copy {}",
1502                copy_id
1503            );
1504
1505            if self.copy_is_permitted(context, copy_id)? {
1506                context.target = copy_id;
1507            }
1508        }
1509
1510        Ok(())
1511    }
1512
1513    fn apply_copy_target(&mut self, context: &mut HoldTargetContext) -> EgResult<()> {
1514        log::info!("{self} successfully targeted copy: {}", context.target);
1515
1516        let values = eg::hash! {
1517            "current_copy": context.target,
1518            "prev_check_time": "now"
1519        };
1520
1521        self.update_hold(context, values)
1522    }
1523
1524    /// Target one hold by ID.
1525    /// Caller should use this method directly when targeting only one hold.
1526    /// self.init() is still required.
1527    pub fn target_hold(
1528        &mut self,
1529        hold_id: i64,
1530        find_copy: Option<i64>,
1531    ) -> EgResult<HoldTargetContext> {
1532        if !self.transaction_manged_externally {
1533            self.editor().xact_begin()?;
1534        }
1535
1536        let result = self.target_hold_internal(hold_id, find_copy.unwrap_or(0));
1537
1538        if result.is_ok() {
1539            let ctx = result.unwrap();
1540            self.commit()?;
1541            return Ok(ctx);
1542        }
1543
1544        // Not every error condition results in a rollback.
1545        // Force it regardless of whether our transaction is
1546        // managed externally.
1547        self.editor().rollback()?;
1548
1549        let err = result.unwrap_err();
1550
1551        // If the caller only provides an error message and the
1552        // editor has a last-event, return the editor's last event
1553        // with the message added.
1554        if let EgError::Debug(ref msg) = err {
1555            log::error!("{self} exited early with error message {msg}");
1556
1557            if let Some(mut evt) = self.editor().take_last_event() {
1558                evt.set_debug(msg);
1559                return Err(EgError::from_event(evt));
1560            }
1561        }
1562
1563        Err(err)
1564    }
1565
1566    /// Runs through the actual targeting logic w/o concern for
1567    /// transaction management.
1568    fn target_hold_internal(
1569        &mut self,
1570        hold_id: i64,
1571        find_copy: i64,
1572    ) -> EgResult<HoldTargetContext> {
1573        self.hold_id = hold_id;
1574
1575        let hold = self
1576            .editor()
1577            .retrieve("ahr", hold_id)?
1578            .ok_or("No such hold")?;
1579
1580        let mut context = HoldTargetContext::new(hold_id, hold);
1581        let ctx = &mut context; // local shorthand
1582        ctx.find_copy = find_copy;
1583
1584        if !self.hold_is_targetable(ctx) {
1585            return Ok(context);
1586        }
1587
1588        if self.hold_is_expired(ctx)? {
1589            // Exit early if the hold is expired.
1590            return Ok(context);
1591        }
1592
1593        self.get_hold_copies(ctx)?;
1594        self.update_copy_maps(ctx)?;
1595        self.handle_hopeless_date(ctx)?;
1596
1597        if self.hold_has_no_copies(ctx, false, false)? {
1598            // Exit early if we have no copies.
1599            return Ok(context);
1600        }
1601
1602        // Trim the set of working copies down to those that are
1603        // currently targetable.
1604        self.filter_copies_by_status_and_targeted(ctx);
1605        self.filter_closed_date_copies(ctx)?;
1606
1607        if self.inspect_previous_target(ctx)? {
1608            // Exits early if we are retargeting the previous copy.
1609            return Ok(context);
1610        }
1611
1612        self.log_unfulfilled_hold(ctx)?;
1613
1614        if self.hold_has_no_copies(ctx, false, true)? {
1615            // Exit early if we have no copies.
1616            return Ok(context);
1617        }
1618
1619        // At this point, the working list of copies has been trimmed to
1620        // those that are targetable at a superficial level.  (They are
1621        // holdable and available).  Now the code steps through these
1622        // copies in order of priority/proximity to find a copy that is
1623        // confirmed targetable by policy.
1624
1625        self.attempt_force_recall_target(ctx);
1626
1627        if self.attempt_to_find_copy(ctx)? {
1628            // Hold was canceled while seeking a target.
1629            return Ok(context);
1630        }
1631
1632        self.attempt_prev_copy_retarget(ctx)?;
1633
1634        if ctx.target > 0 {
1635            // At long great last we found a copy to target.
1636            self.apply_copy_target(ctx)?;
1637            ctx.success = true;
1638        } else {
1639            // Targeting failed.  Make one last attempt to process a
1640            // recall and mark the hold as un-targeted.
1641            self.hold_has_no_copies(ctx, true, true)?;
1642        }
1643
1644        Ok(context)
1645    }
1646}