Lending Library: Hold Shelf, Transit and Frozen Holds
MediumAI-assisted coding · AI-Assisted Feature Work
Lending Library: Hold Shelf, Transit and Frozen Holds
You've joined the circulation team of a city library system with four branches. The checkout, return, renewal and fine code has been running for years. The next release adds holds: a member queues for a *title* (not a particular copy), and when a copy comes free it is set aside on the hold shelf at the member's chosen pickup branch. Nobody has built the shelf yet, so today a returned book that someone is waiting for just goes back on the open shelf. All money is integer cents; all times are whole days (ts is a day number, and it never decreases from one operation to the next).
Codebase
| File | What it does |
|---|---|
library/api.py | run(operations) entry point and the Dispatcher that routes each operation |
library/service.py | Library: wires the desks together; advance(ts) moves the clock and handles due events |
library/loans.py | LoanDesk: checkout, return (with fines), renew; check_may_borrow / open_loan are shared with pickup |
library/holds.py | HoldDesk: place, cancel, pick up and report holds. Most of your work is here |
library/clock.py | Clock and the EventQueue of timed events (same-day events pop by kind, then hold number) |
library/catalog.py | Catalog: copies, titles, branches, transit_days, and the index of available copies |
library/members.py | MemberRegistry: members and the fines they owe |
library/fines.py | compute_fine: the overdue-fine rule (known-good) |
library/bulk.py | Bulk operations used by the branch-migration importer (they call the single-operation methods) |
library/models.py | Copy, Member, Loan, Hold records and the status constants |
library/config.py | Policy constants: shelf days, grace days, item kinds, tiers, transit table |
library/errors.py | Every error code |
tests/ | Existing unit tests (one currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh library and returns one result per operation. Every operation is [name, ts, ...args]. Errors are returned as "error: <code>" strings, never raised. Wrong argument count or a non-integer ts gives "error: invalid arguments"; an unknown name gives "error: unknown operation". Ids (members, copies, titles) are case-sensitive strings. Wherever ids are ordered, it is plain string comparison. Branches are EAST, MAIN, NORTH, WEST.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
join | member_id, tier | true | member exists, unknown tier |
add_copy | copy_id, title_id, branch, kind | true | copy exists, unknown branch, unknown kind |
checkout | member_id, copy_id | due day | member not found, copy not found, fines block, loan limit, copy unavailable |
return | copy_id, branch | fine charged (cents) | copy not found, not on loan, unknown branch |
renew | member_id, copy_id | new due day | member not found, copy not found, not on loan, overdue, renewal limit |
fines | member_id | cents owed | member not found |
pay_fine | member_id, amount | cents still owed | member not found, invalid amount, overpayment |
place_hold | member_id, title_id, pickup | hold id "h-<n>" | member not found, title not found, unknown branch, fines block, duplicate hold |
cancel_hold | member_id, hold_id | true | member not found, hold not found, not your hold, hold closed |
pickup | member_id, hold_id | due day | member not found, hold not found, not your hold, hold not ready, fines block, loan limit |
hold_status | hold_id | {"status", "copy", "pickup", "last_day"} | hold not found |
The loan, fine and renewal rules already work; config.py, loans.py and fines.py are the reference. In short: the due day is the checkout day plus the kind's loan days; a renewal restarts the period from the renewal day (renew_day + loan_days), is refused after the due day, and is limited to MAX_RENEWALS; a fine is 0 if the item comes back within GRACE_DAYS of the due day, otherwise *every* late day is charged at the kind's daily rate, scaled by the member's tier percentage (adult 100%, senior 50%, youth 0%, rounded half up to a cent), then capped per item. A member owing FINE_BLOCK_CENTS or more cannot check out, place holds or pick up. Hold ids are h-1, h-2, ... in the order holds are successfully placed; that number is the hold's queue order on its title. A member may have at most one open hold per title (open = not yet collected, expired or cancelled). hold_status reports status (queued, suspended, in_transit, ready, collected, expired, cancelled), copy (the copy id while the hold is in_transit or ready, otherwise null), pickup, and last_day (the last day a ready hold can be picked up, otherwise null).
The branch-migration importer also has four bulk operations (they behave exactly like the equivalent run of single operations at the same ts; on a failure the first error is returned and the earlier ones stay applied). Ids are prefix + a zero-padded index: f"{prefix}{i:04d}", for i = 0 .. count-1. Each returns count.
| Operation | Arguments after ts | Same as |
|---|---|---|
bulk_join | prefix, count, tier | join for each member |
bulk_add_copies | title_id, prefix, count, branch, kind | add_copy for each copy |
bulk_hold | title_id, prefix, count, pickup | place_hold for member prefix0000, prefix0001, ... in that order |
bulk_suspend | title_id, prefix, start, count, until | suspend_hold (Part 3) for each member with index start .. start+count-1, on their open hold for the title |
Worked example (works today)
[["join", 0, "ana", "adult"],
["add_copy", 0, "c-001", "t-dune", "MAIN", "book"],
["checkout", 1, "ana", "c-001"],
["renew", 10, "ana", "c-001"],
["return", 40, "c-001", "EAST"],
["fines", 40, "ana"]]returns [true, true, 22, 31, 225, 225] (due 31 after the renewal; nine days late at 25 cents).
Part 1 — Fill holds from returns, expire them lazily, protect waiting holds (LIB-212)
Goal: make holds actually work for copies that are already at the member's pickup branch.
Rules
- A copy *becomes available* when it is returned, added with
add_copy, released by a cancelled or expired hold, or (Part 3) when a hold resumes. Whenever a title has a waiting hold and an available copy, the lowest hold number among its waiting holds gets the copy, one pair at a time until either runs out. This also applies when a member places a hold on a title that already has an available copy: the hold is filled at once. - Filling sets the copy aside: the hold becomes
ready, the copy is on the hold shelf at the pickup branch (in Part 1 it is simply there from the first moment; copies are never in transit), and the hold can be picked up on any day from the fill day throughlast_day = fill_day + HOLD_SHELF_DAYS - 1. Several available copies: the lowest copy id. pickupturns areadyhold into a loan on that copy (same due-day rule ascheckout, samefines blockandloan limitchecks viacheck_may_borrow; a failed pickup leaves the holdready). A hold in any other status giveshold not ready.- Time is lazy. Before each operation runs, every event whose day is
<= tsis handled, in ascending event day, and same-day events in ascending hold number. The only event in Part 1 is a hold lapsing: areadyhold whoselast_dayhas passed becomesexpiredon daylast_day + 1, its copy becomes available *as of that day*, and it is offered to the next waiting hold, which becomes ready as of that day (not as of thetsthat happened to trigger the processing). Chains of lapses between two operations must all play out, each with its own dates. cancel_holdworks on any open hold. Cancelling areadyhold makes its copy available on the cancelling day. Acollected,expiredorcancelledhold giveshold closed. A hold that was cancelled or collected never lapses afterwards.renewis additionally refused withtitle has holdswhen the loaned copy's title has any hold waiting for a copy (queued; a hold that already has a copy set aside does not count). Check order:not on loan,overdue,renewal limit, thentitle has holds.
Example (after the three joins of ana, ben, cy and the add_copy of c-001, all at MAIN)
[["checkout", 1, "ana", "c-001"],
["place_hold", 2, "ben", "t-dune", "MAIN"],
["place_hold", 3, "cy", "t-dune", "MAIN"],
["renew", 4, "ana", "c-001"],
["return", 5, "c-001", "MAIN"],
["hold_status", 5, "h-1"],
["pickup", 12, "ben", "h-1"],
["hold_status", 12, "h-1"],
["hold_status", 12, "h-2"],
["pickup", 14, "cy", "h-2"]]returns [22, "h-1", "h-2", "error: title has holds", 0, {"status": "ready", "copy": "c-001", "pickup": "MAIN", "last_day": 11}, "error: hold not ready", {"status": "expired", "copy": null, "pickup": "MAIN", "last_day": null}, {"status": "ready", "copy": "c-001", "pickup": "MAIN", "last_day": 18}, 35]. Ben's hold lapsed on day 12, so Cy's became ready on day 12 (not 14).
Part 2 — Copies travel between branches (LIB-231)
Change of requirements. Logistics now runs a courier van, and a copy is no longer teleported. This replaces the "copies are never in transit" simplification of Part 1.
Rules
- A copy always has a current
branch(where it was added or returned, or where its last hold shelved it). When the first waiting hold takes an available copy, travel time istransit_days(copy.branch, hold.pickup)(seecatalog.pyand the table inconfig.py;0when they are the same branch). - With travel time
0nothing changes from Part 1. Otherwise the hold becomesin_transit(copyshows the copy id,last_dayisnull) and the copy is not available. On dayfill_day + travelit arrives: the copy's branch becomes the pickup branch, the hold becomesreadyas of the arrival day, andlast_day = arrival_day + HOLD_SHELF_DAYS - 1. The shelf clock starts at arrival, not at fill. Anin_transithold cannot be picked up (hold not ready). - When several copies are available for the hold at the head of the queue, choose the one with the smallest travel time to the pickup branch, ties by lowest copy id (this replaces "lowest copy id").
- Cancelling an
in_transithold does not stop the van: the copy arrives at the hold's pickup branch on the arrival day, becomes available there as of that day, and is offered to the next waiting hold (possibly sending it on again from that branch). - Event order within one day is now: arrivals before lapses, then by hold number. An arrival or lapse handled at event day
Efills holds as ofE. - Everything in Part 1 still holds; the Part 1 example is unchanged because all branches there match.
Example (after join of ana and ben, add_copy c-001 t-dune MAIN book)
[["checkout", 1, "ana", "c-001"],
["place_hold", 2, "ben", "t-dune", "NORTH"],
["return", 5, "c-001", "EAST"],
["hold_status", 5, "h-1"],
["pickup", 7, "ben", "h-1"],
["hold_status", 8, "h-1"],
["hold_status", 15, "h-1"]]returns [22, "h-1", 0, {"status": "in_transit", "copy": "c-001", "pickup": "NORTH", "last_day": null}, "error: hold not ready", {"status": "ready", "copy": "c-001", "pickup": "NORTH", "last_day": 14}, {"status": "expired", "copy": null, "pickup": "NORTH", "last_day": null}] (EAST to NORTH is 3 days, so the copy arrives on day 8).
Part 3 — "Freeze my hold" and long queues (LIB-260)
Goal: members going on vacation can freeze a hold without losing their place in line.
| Operation | Arguments after ts | Returns | Errors (in order) |
|---|---|---|---|
suspend_hold | member_id, hold_id, until | true | member not found, hold not found, not your hold, hold not waiting, invalid date |
resume_hold | member_id, hold_id | true | member not found, hold not found, not your hold, hold not waiting, hold not suspended |
hold_position | hold_id | integer | hold not found, hold not waiting |
Rules
- Only
queuedorsuspendedholds are *waiting* (otherwisehold not waiting).untilisnull(frozen until resumed by hand) or an integer greater thants(elseinvalid date). Suspending asuspendedhold just replaces itsuntil. - A suspended hold keeps its hold number, is never offered a copy, and is skipped when filling: the next
queuedhold on the title gets the copy. Resuming (by hand, or automatically at the start of dayuntil: the hold isqueuedfor any operation atts >= until) puts it back in its original place in line, and if a copy is available it is filled right away, as of the resume day. - A pending automatic resume that no longer applies (the hold was resumed by hand, suspended again with a different
until, cancelled) must do nothing. - Event order within one day is now: resumes, then arrivals, then lapses, then by hold number. So a hold resuming on the day another hold lapses is back in line before the freed copy is offered.
- Renewals change: suspended holds no longer block renewals; only
queuedholds do. hold_positionis1 +the number ofqueuedholds on the same title with a smaller hold number (suspended holds ahead do not count; for a suspended hold this is where it would sit if resumed).- Bulk operations (
bulk_join,bulk_add_copies,bulk_hold,bulk_suspend, seebulk.py) keep working and now include real suspensions. Hidden tests use them to build queues with tens of thousands of holds, thousands of which are frozen, and then trigger thousands of fills and lapses in onerun. The whole suite has a few seconds; rescanning a queue for every fill will not finish.
Example (after joining ana, ben, cy, dee, and add_copy c-001 t-dune MAIN book)
[["checkout", 1, "ana", "c-001"],
["place_hold", 2, "ben", "t-dune", "MAIN"],
["place_hold", 2, "cy", "t-dune", "MAIN"],
["place_hold", 2, "dee", "t-dune", "MAIN"],
["suspend_hold", 3, "ben", "h-1", 10],
["hold_position", 3, "h-1"],
["hold_position", 3, "h-2"],
["hold_position", 3, "h-3"],
["return", 5, "c-001", "MAIN"],
["hold_position", 10, "h-1"],
["hold_status", 12, "h-1"]]returns [22, "h-1", "h-2", "h-3", true, 1, 1, 2, 0, 1, {"status": "ready", "copy": "c-001", "pickup": "MAIN", "last_day": 18}]. The copy went to Cy (Ben was frozen). Ben resumed on day 10 in front of Dee. When Cy's hold lapsed on day 12 the copy went to Ben, not Dee.
Notes
fines.py,config.pyandcatalog.pyare known-good. You may use the AI assistant freely; you are expected to verify what it produces.- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
- Parts 1-2 are the core result. Part 3 is the stretch.
Example 1
- Input:
- [["join",0,"ana","adult"],["join",0,"ben","adult"],["join",0,"cy","adult"],["add_copy",0,"c-001","t-dune","MAIN","book"],["checkout",1,"ana","c-001"],["place_hold",2,"ben","t-dune","MAIN"],["place_hold",3,"cy","t-dune","MAIN"],["renew",4,"ana","c-001"],["return",5,"c-001","MAIN"],["hold_status",5,"h-1"],["pickup",12,"ben","h-1"],["hold_status",12,"h-1"],["hold_status",12,"h-2"],["pickup",14,"cy","h-2"]]
- Output:
- [true,true,true,true,22,"h-1","h-2","error: title has holds",0,{"status":"ready","copy":"c-001","pickup":"MAIN","last_day":11},"error: hold not ready",{"status":"expired","copy":null,"pickup":"MAIN","last_day":null},{"status":"ready","copy":"c-001","pickup":"MAIN","last_day":18},35]
- Explanation
- Ana returns the only copy on day 5, so it goes to Ben (first in line) and is set aside through day 11. Ben never collects it: on day 12 the hold lapses and the copy is immediately Cy's, ready as of day 12 (not day 14), through day 18.
Example 2
- Input:
- [["join",0,"ana","adult"],["join",0,"ben","adult"],["add_copy",0,"c-001","t-dune","MAIN","book"],["checkout",1,"ana","c-001"],["place_hold",2,"ben","t-dune","NORTH"],["return",5,"c-001","EAST"],["hold_status",5,"h-1"],["pickup",7,"ben","h-1"],["hold_status",8,"h-1"],["hold_status",15,"h-1"]]
- Output:
- [true,true,true,22,"h-1",0,{"status":"in_transit","copy":"c-001","pickup":"NORTH","last_day":null},"error: hold not ready",{"status":"ready","copy":"c-001","pickup":"NORTH","last_day":14},{"status":"expired","copy":null,"pickup":"NORTH","last_day":null}]
- Explanation
- A copy returned at EAST for a hold with pickup NORTH takes three days on the van. The shelf clock starts at arrival (day 8), so the last pickup day is 14 and the hold lapses on day 15.
Example 3
- Input:
- [["join",0,"ana","adult"],["join",0,"ben","adult"],["join",0,"cy","adult"],["join",0,"dee","adult"],["add_copy",0,"c-001","t-dune","MAIN","book"],["checkout",1,"ana","c-001"],["place_hold",2,"ben","t-dune","MAIN"],["place_hold",2,"cy","t-dune","MAIN"],["place_hold",2,"dee","t-dune","MAIN"],["suspend_hold",3,"ben","h-1",10],["hold_position",3,"h-1"],["hold_position",3,"h-2"],["hold_position",3,"h-3"],["return",5,"c-001","MAIN"],["hold_position",10,"h-1"],["hold_status",12,"h-1"]]
- Output:
- [true,true,true,true,true,22,"h-1","h-2","h-3",true,1,1,2,0,1,{"status":"ready","copy":"c-001","pickup":"MAIN","last_day":18}]
- Explanation
- Ben freezes his hold (first in line) until day 10. The returned copy skips him and goes to Cy. On day 10 Ben is back in front of Dee, so when Cy's hold lapses on day 12 the copy goes to Ben.
Guidelines
- Python 3 standard library only
- Times are whole days; ts never decreases from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Hold numbers (h-1, h-2, ...) are the queue order; same-day events are handled by kind, then hold number
- Part 3 inputs build queues of tens of thousands of holds and cause thousands of fills, resumes and lapses in a single run