delivery progress
Keep a Delivery honest about what a long transfer is doing, including when it is cancelled and when it crashes
A transfer runs for minutes or hours with nobody watching it. The Delivery row is the only thing a producer can read while it runs, so a client writes to it as it goes, and writes to it again on the way out of a crash.
On the probed site Delivery holds zero rows site-wide, so everything below was measured against rows
this recipe created in the sandbox and deleted. There is no entity_types/Delivery
card yet; what the type does is recorded here.
The fields a transfer writes
Delivery has 32 fields. These are the ones a progress reporter touches.
| field | display name | data type | editable | holds |
|---|---|---|---|---|
sg_status_list |
Status |
status_list | yes | the short code |
sg_delivery_progress |
Delivery Progress |
list | yes | the stage, with cancelled and failed distinct |
description |
Description |
text | yes | the free-text progress line |
sg_contents |
Contents |
text | yes | what is being sent, not how far it got |
title |
Title |
text | yes | server-generated New Delivery <id> when omitted |
delivery_number |
Delivery # |
text | no | server-assigned, the id as a string, and not reused after a delete |
sg_versions |
Version <-> Link |
multi_entity | yes | valid_types: ['Version'] |
version_sg_deliveries_versions |
Versions |
multi_entity | yes | valid_types: ['Version'], a second, independent link |
sg_published_files |
Published File <-> Link |
multi_entity | yes | valid_types: ['PublishedFile'] |
attachments |
Attachments |
multi_entity | yes | valid_types: ['Attachment'], filled by the upload dance |
replies |
Replies |
multi_entity | yes | valid_types: ['Reply'] |
sg_delivery_method |
Delivery Method |
list | yes | site configuration; on the probed site ['FTP', 'Aspera', 'FedEx', 'Sneaker Net'] |
sg_upload_url |
Upload URL |
url | yes | where the far end fetches from |
Two Version links, not one. sg_versions and version_sg_deliveries_versions both take Versions and
neither mirrors the other. Writing one leaves the other empty:
PUT sg_versions 200 {"sg_versions": [<vid>], "version_sg_deliveries_versions": [], "sg_published_files": []}
PUT version_sg_deliveries_versions 200 {"sg_versions": [<vid>], "version_sg_deliveries_versions": [<vid>], "sg_published_files": []}
PUT sg_versions=[] 200 {"sg_versions": [], "version_sg_deliveries_versions": [<vid>], "sg_published_files": []}
Pick one and write both if the site's pages read both. A bare list replaces the whole set;
{"multi_entity_update_mode": "add", "value": [...]} appends (field_types/multi_entity).
The two vocabularies
Read them; the codes below are one site's. valid_values was byte-identical at site scope and at
sandbox-project scope for both fields, and hidden_values was [] on sg_status_list and null on
sg_delivery_progress (probe 009). hidden_values is not a subset of valid_values, so subtract, never
assume (field_types/status_list).
| field | valid_values on the probed site |
default_value |
|---|---|---|
sg_status_list |
['opn', 'ip', 'dlvr', 'recd'] |
opn |
sg_delivery_progress |
['In transit', 'Delivery cancelled', 'Delivery failed', 'Delivered', 'Ingesting', 'Ingest cancelled', 'Ingest failed', 'Ingest suspended', 'Received', 'Received with warnings', 'Transcode cancelled'] |
null |
p = get("/schema/Delivery/fields/sg_status_list",
params={"project_id": PROJECT_ID}).json()["data"]["properties"]
valid = (p.get("valid_values") or {}).get("value") or []
hidden = (p.get("hidden_values") or {}).get("value") or []
usable = [v for v in valid if v not in hidden]
Create
project is the only required attribute. Everything else the server invents or leaves null.
| body sent | result |
|---|---|
{} |
400 code 103 API create() missing 'project' attribute: {} |
{"project": {"type": "Project", "id": N}} |
201, id 21, title "New Delivery 21", delivery_number "21", sg_status_list "opn", sg_delivery_progress null |
{"project": …, "title": "…"} |
201, the title as sent |
Call
import traceback
import requests
# get/post/put are FPT.get/.post/.put from src/sg_groundtruth/client.py; they add auth and the /api/v1 prefix.
# The caller supplies PROJECT_ID, VERSION_IDS, MEDIA and PAPERWORK as [(filename, bytes)], log(msg),
# and check_cancelled(), which raises Cancelled when the operator stops the job.
JSON = {"Content-Type": "application/json"}
class Cancelled(Exception):
pass
def usable(field):
"""Read the vocabulary per project. hidden_values is not a subset of valid_values."""
p = get(f"/schema/Delivery/fields/{field}",
params={"project_id": PROJECT_ID}).json()["data"]["properties"]
valid = (p.get("valid_values") or {}).get("value") or []
hidden = (p.get("hidden_values") or {}).get("value") or []
return [v for v in valid if v not in hidden]
def pick(vocab, *wanted):
"""No code is portable. Read the vocabulary; None means the free-text line says it instead."""
return next((w for w in wanted if w in vocab), None)
def say(delivery_id, code, stage, line):
"""The pair, in one PUT, then a re-read: a 200 is not proof the value landed (probe 028)."""
body = {"description": line}
if code:
body["sg_status_list"] = code
if stage:
body["sg_delivery_progress"] = stage
put(f"/entity/deliveries/{delivery_id}", headers=JSON, json=body)
return get(f"/entity/deliveries/{delivery_id}",
params={"fields": "sg_status_list,sg_delivery_progress,description"}
).json()["data"]["attributes"]
def upload(delivery_id, filename, payload):
"""Three calls. No field in the path, so the file lands as an Attachment (probes 013, 014)."""
b = get(f"/entity/deliveries/{delivery_id}/_upload", params={"filename": filename}).json()
requests.put(b["links"]["upload"], data=payload).raise_for_status()
post(b["links"]["complete_upload"], headers=JSON,
json={"upload_info": b["data"], "upload_data": {}}).raise_for_status()
codes = usable("sg_status_list")
stages = usable("sg_delivery_progress")
OPEN, RUNNING, DONE = pick(codes, "opn"), pick(codes, "ip"), pick(codes, "dlvr", "recd")
delivery_id = post("/entity/deliveries", headers=JSON, json={
"project": {"type": "Project", "id": PROJECT_ID}, # the only required attribute
"title": "nightly_delivery",
"sg_versions": [{"type": "Version", "id": i} for i in VERSION_IDS],
}).json()["data"]["id"]
code, stage, line, failure = OPEN, None, "queued", None
try:
say(delivery_id, OPEN, None, f"queued: {len(MEDIA)} file(s)")
for n, (filename, payload) in enumerate(MEDIA, 1):
check_cancelled()
upload(delivery_id, filename, payload) # load-bearing: nothing catches this
code, stage = RUNNING, pick(stages, "In transit")
line = f"in transit: {n}/{len(MEDIA)} file(s)"
say(delivery_id, code, stage, line)
for filename, payload in PAPERWORK:
try:
upload(delivery_id, filename, payload)
except Exception as e:
log(f"skipped {filename}: {e}") # paperwork does not sink a good delivery
code, stage, line = DONE, pick(stages, "Delivered"), f"delivered: {len(MEDIA)} file(s), 0 errors"
except Cancelled:
stage, line = pick(stages, "Delivery cancelled"), "cancelled by the operator"
except Exception:
failure = traceback.format_exc()
stage, line = pick(stages, "Delivery failed"), failure.strip().splitlines()[-1]
finally:
say(delivery_id, code, stage, line) # the record outlives the crash
if failure:
post("/entity/replies", headers=JSON, json={
"entity": {"type": "Delivery", "id": delivery_id}, # never omit; see Notes
"content": failure})
Response
The block above, extracted and run three times against one Version in the sandbox. MEDIA is one PNG,
PAPERWORK is a manifest and one file whose generator produced an empty name, which the upload refuses
at the first call: 400 Request Parameters invalid. source {"filename": ["filename must be filled"]}.
reader sees is the re-read inside say(), which is what a producer opening the page would find.
run 1, clean
POST /entity/deliveries 201 id=15
reader sees {"sg_status_list": "opn", "sg_delivery_progress": null, "description": "queued: 1 file(s)"}
init 'delivery_media.png' 200 ; PUT S3 200 ; complete_upload 201
reader sees {"sg_status_list": "ip", "sg_delivery_progress": "In transit", "description": "in transit: 1/1 file(s)"}
init 'manifest.json' 200 ; complete_upload 201
init '' 400 ; log: skipped : 'links'
reader sees {"sg_status_list": "dlvr", "sg_delivery_progress": "Delivered", "description": "delivered: 1 file(s), 0 errors"}
Delivery.attachments -> ['delivery_media.png', 'manifest.json'] Delivery.replies -> []
run 2, the media upload crashes (the same empty name, this time in MEDIA)
reader sees {"sg_status_list": "ip", "sg_delivery_progress": "In transit", "description": "in transit: 1/2 file(s)"}
init '' 400
reader sees {"sg_status_list": "ip", "sg_delivery_progress": "Delivery failed", "description": "KeyError: 'links'"}
POST /entity/replies 201 id=550, content 4 lines
Delivery.attachments -> ['delivery_media.png'] Delivery.replies -> [550]
run 3, the operator stops it after the first file
reader sees {"sg_status_list": "ip", "sg_delivery_progress": "In transit", "description": "in transit: 1/2 file(s)"}
reader sees {"sg_status_list": "ip", "sg_delivery_progress": "Delivery cancelled", "description": "cancelled by the operator"}
Delivery.attachments -> ['delivery_media.png'] Delivery.replies -> []
Run 2 and run 3 both end on sg_status_list ip, because the vocabulary has no terminal code for either
outcome. The whole distinction between a stop and a bug is sg_delivery_progress, the free-text line, and
the presence of a Reply.
Cancellation
Cancellation is expressible on this site, and not on the field a client reaches for first.
sg_status_list has four codes and none of them is terminal-bad:
PUT {"sg_status_list": "cancelled"} 400 Update failed for [Delivery.sg_status_list]: 'cancelled' is not
a valid status. Valid statuses: 'opn', 'ip', 'dlvr', 'recd'.
PUT {"sg_status_list": "failed"} 400 the same message, 'failed'
sg_delivery_progress separates the two outcomes and separates a delivery failure from an ingest failure:
written to sg_delivery_progress |
result |
|---|---|
"Delivery cancelled" |
200, reads back as sent |
"Delivery failed" |
200, reads back as sent |
"delivery cancelled" |
400 'delivery cancelled' is not a valid list value. Valid list values: 'In transit', 'Delivery cancelled', … |
"Cancelled" |
400, the same message |
Values are case-sensitive on write and contain spaces, so round-trip the string out of valid_values
rather than typing it (field_types/list).
sg_delivery_progress is site configuration. On a site whose Delivery lacks it, or whose vocabulary omits
these values, the status code cannot tell a stop from a bug and the client must encode the distinction
itself: the free-text line and a Reply are then the only record of which one happened. That is a
site-configuration answer, not an API one.
Load-bearing and skippable
The survey of production code found one rule applied consistently across three consumer applications of one delivery framework: the media fails loudly, the paperwork is caught, logged and skipped.
| artefact | on failure | why |
|---|---|---|
| the media | raise, mark the Delivery failed, post the traceback | the far end has nothing to work from |
| the manifest | log and skip | regenerable from the Delivery's own links |
| the edit decision list | log and skip | regenerable from the Versions |
| the transfer log | log and skip | describes the run, not the delivery |
the status write in finally |
never skip | it is the only record a producer reads |
The API enforces none of this. Every upload is the same three calls to the same endpoint and each answers 201 on its own; which ones a client wraps in a try is a policy the client brings.
Notes
- Write the pair from a
finally, not from the success path. An uncaught exception between two progress writes leaves a Delivery readingipand a line describing work that stopped an hour ago, and nothing in the API times a row out. - A
200on the write is not proof of the value. Re-read the row (probe 028);say()above returns the re-read, not the response to thePUT. - Always send
entityon the Reply. A Reply created without one cannot be deleted:DELETE /entity/replies/<id>answers 400 code 104undefined method 'reflect_on_association' for class NilClass(entity_types/Reply). A failure reporter that drops the link leaves permanent litter on exactly the runs that already went wrong. Reply.entitynames 113 of the 114 types in/schema,Deliveryamong them, andDelivery.repliesis one of only two fields anywhere withvalid_types: ['Reply'](entity_types/Reply). A Reply on a Delivery is ordinary, and it is readable back both fromDelivery.repliesand fromPOST /entity/replies/_searchon[["entity", "is", {"type": "Delivery", "id": N}]].Delivery.reply_contentis not the thread. It read'Warning: If you see this displayed in the UI, it means the widget is not respecting grid_column = false.'on a Delivery holding one real Reply. Readreplies.Reply.cached_display_namecomes back HTML-escaped wherecontentdoes not: a traceback containing"reads back with"incached_display_nameand with"incontentand in thenameof theDelivery.replieslink.- Writing
""todescriptionstoresnull, so an empty progress line erases the previous one rather than blanking it (field_types/text). Send a real line every time. sg_delivery_typehasvalid_values: []on the probed site, so every write to it is400 … 'Final' is not a valid list value. Valid list values: ''.An empty vocabulary is a field that cannot be set, not a free-text field.- Uploading with no field in the path stores the file as an Attachment on
Delivery.attachments(probes 013, 014). Delete the Attachment rows, not just the link, when a run is rolled back.