This repository provides an authenticated Django REST API for managing Handle.Net persistent identifiers (PIDs). The API enqueues operations and processes them asynchronously in a background worker that talks to the Handle server via Java CLI tools.
Key principles
- Async by default:
POST/DELETEmutate operations return 202 Accepted with a request id. Results are retrieved by polling/handle-ops/{id}.- Pattern A (write-on-success): The local
Handletable is updated only after the Handle server confirms success. No local “pending/error” mirror.- One in-flight op per handle: The API rejects a new op for a handle when another is queued/running (HTTP 423 Locked).
- Strict semantics:
- CREATE fails if a local handle already exists (active or deleted). Handles are never recycled.
- UPDATE fails if the handle is missing or deleted.
- DELETE fails if the handle is missing or already deleted.
- No idempotency header:
Idempotency-Keyis not used in this iteration.
- Architecture
- Data Model
- API
- Background Worker
- Configuration
- Local Development
- Admin
- Deploy & Operations
- Migrations & Breaking Changes
- Changelog (2025-09)
Client
│ HTTP (Token)
▼
Django REST API ──(enqueue)──► Postgres (requests, confirmed handles)
│ ▲
│ │
└──poll /handle-ops/{id}◄───────────┘
│
▼
Background Worker ──► Java CLI ──► Handle.Net server (authoritative)
(subprocess)
- Source of truth for handle values is the Handle server.
- Django stores:
HandleOperationRequestrows for in-flight / logs / outcomes.Handlerows as a confirmed local mirror (only after success).
- Fields:
handle(suffix, unique),full_handle(prefix/suffix, unique),current_url,version,status,last_request, timestamps. status:activeordeletedonly.- No
pending/errorhere—those states exist only on requests.
- No
- Deletion never removes rows; status flips to
deletedto prevent suffix recycling. last_requestreferences the last successfulHandleOperationRequest.
- Fields:
op(create|update|delete|remote_resolve|remote_list),status(queued|running|success|error),handle,full_handle,url(for create/update), attempts/timings, and logs. - CheckConstraint:
urlmust be present whenop in ('create','update'). - Note:
idempotency_keyhas been removed.
All endpoints require DRF token auth:
python manage.py drf_create_token <username>
# Use header: Authorization: Token <token>| Method | Path | Purpose | Notes |
|---|---|---|---|
| GET | /handles/ |
List local handles (Postgres mirror; non-authoritative) | Sync |
| POST | /handles/ |
Enqueue CREATE for a new handle | 202 + request id |
| GET | /handles/{handle_id}/ |
Local resolve (read the Handle row) |
404 if not found |
| POST | /handles/{handle_id}/ |
Enqueue UPDATE of target URL | 202 + request id |
| DELETE | /handles/{handle_id}/ |
Enqueue DELETE (mark local row deleted on success) |
202 + request id |
| GET | /handles/remote/{handle_id}/ |
Enqueue remote resolve against Handle.Net (authoritative) | 202 + request id; result in java_stdout |
| GET | /handles/remote/ |
Enqueue remote list for the configured prefix | 202 + request id; one handle per line in java_stdout |
| GET | /handle-ops/{id} |
Get request status/logs (queued/running/success/error) | Sync |
| GET | /health/ (if enabled) |
Health check | Sync |
Async pattern: mutate/remote endpoints return 202 Accepted and a HandleOperationRequest summary; clients poll /handle-ops/{id} to read status and logs (java_stdout, java_stderr, error).
Handle read
{
"id": 1,
"handle": "example123",
"full_handle": "20.500.14725/example123",
"version": 2,
"current_url": "https://repo.org/item/123",
"status": "active",
"last_request": 42,
"last_synced_at": "2025-09-01T12:34:56Z",
"created_at": "2025-08-26T23:45:00Z",
"updated_at": "2025-09-01T12:34:56Z"
}HandleOperationRequest summary
{
"id": 42,
"op": "update",
"handle": "example123",
"full_handle": "20.500.14725/example123",
"url": "https://new.example.org/item/123",
"status": "queued",
"attempts": 0,
"started_at": null,
"finished_at": null,
"created_at": "2025-09-01T12:30:00Z"
}- 423 Locked — returned synchronously if another request for the same
full_handleisqueuedorrunning. (One in-flight op per handle.) - 404 Not Found — for
/handles/{handle_id}/reads when the local mirror is missing. - Asynchronous validation failures (strict rules below) result in the request eventually reaching
status="error", visible via/handle-ops/{id}. Examples:- CREATE for an existing local handle (active or deleted)
- UPDATE for a missing/deleted handle
- DELETE for a missing/already-deleted handle
The API intentionally accepts these operations (202) and enforces strict preflights in the worker to keep the API thin and the system consistent.
- Runs a loop with a
ThreadPoolExecutor(HANDLE_WORKER_THREADS). - Claim shaping: claims at most one queued request per
full_handleper tick. - Per-handle lock: uses Postgres advisory locks to ensure only one job per handle executes concurrently (even across processes).
- Pattern A write-back: only mutates the
Handlerow on success:- CREATE success → insert new row (
active,version=1). - UPDATE success → require row exists & not deleted; bump
version, set URL,active. - DELETE success → require row exists; set
deleted(never remove the row).
- CREATE success → insert new row (
- Strict preflights (before calling the Handle server):
- CREATE: error if row exists (active/deleted).
- UPDATE: error if row missing or deleted.
- DELETE: error if row missing or already deleted.
- Reaper: flips long-running
runningrequests toerrorafterHANDLE_WORKER_CLAIM_TIMEOUTseconds to free stuck lanes. - Remote ops:
remote_resolve/remote_listdo not mutateHandle; their output appears injava_stdoutfor clients to parse.
Environment variables (with defaults):
| Variable | Purpose | Default |
|---|---|---|
HANDLE_PREFIX |
Your handle prefix, e.g. 20.500.14725 |
required |
HANDLE_WORKER_POLL_INTERVAL |
Worker loop sleep in seconds | 2.0 |
HANDLE_WORKER_BATCH |
Max jobs to claim per tick | 5 |
HANDLE_WORKER_THREADS |
Worker thread pool size | 2 |
HANDLE_WORKER_LOCK_ID |
Leader election advisory lock id | 726381920 |
HANDLE_WORKER_CLAIM_TIMEOUT |
Seconds before a running job is reaped as error |
900 |
HANDLE_WORKER_BATCH_SCAN_FACTOR |
Oversampling factor to pick distinct handles per tick | 4 |
Concurrency tuning: With THREADS=N, at most N different handles will execute concurrently. Additional jobs remain queued.
- Python 3.11+ (or containerized environment)
- Postgres
- Java (for the Handle CLI) if running the worker directly
docker compose up
python manage.py migrate
# Create a superuser and token
python manage.py createsuperuser
python manage.py drf_create_token <username>
# Enqueue a create
curl -X POST http://localhost:8000/handles/ -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" -d '{"handle":"example123","url":"https://repo.org/item/123"}'
# Poll the request
curl -H "Authorization: Token $TOKEN" http://localhost:8000/handle-ops/42
# Read the local mirror
curl -H "Authorization: Token $TOKEN" http://localhost:8000/handles/example123/Django Admin provides convenient browsing of both models:
- HandleOperationRequest: filter by
op,status, timings; viewjava_stdout/stderr/error. - Handle: see
status(active/deleted),version,current_url, and link to thelast_requestthat changed it.
- The worker is embedded and starts once per app instance using a leader election advisory lock (only one worker loop per DB).
- Monitor logs for:
Reaped X stuck RUNNING handle opsCREATE/UPDATE/DELETE preflight ...(worker rejections)
- Backups: persist both the Handle server data volume (BerkeleyDB) and the Postgres database.
- Removed:
HandleOperationRequest.idempotency_key. - Changed:
Handle.statusis now onlyactiveordeleted. - Added:
CheckConstraintrequiringurlforcreate&updaterequests. - Indexes:
('full_handle','status'),created_atindices added (see models).
If you have legacy Handle rows with pending/error statuses, normalize them (e.g., to active) via a short data migration to align with Pattern A.
- Pattern A: local
Handlerows are only written on successful upstream operations. - Strict worker preflights for create/update/delete.
- Per-handle concurrency enforced at API (423) and worker (advisory locks).
- Removed idempotency (no
Idempotency-Keyheader). - Schema updates: status vocabulary reduced; URL presence constraint; new indexes.
- Docs: clarified async semantics (202 + poll), error behavior, and configuration knobs.
© 2025 — Internal service for handle management.