Automating Fixity Audits with S3 Batch Operations
The inventory join covers every object whose checksum the platform already knows; everything else has to be read, and reading a few hundred thousand archived objects by hand is not a plan. This walkthrough is for the platform engineer turning that residual work into a scheduled batch job — one that restores what it must, hashes what it reads, writes the result back so the object never needs reading again, and reports in a form an archivist can act on. It assumes the manifest, inventory and rotating-slice design from the parent topic are already in place.
What the Batch Job Is For
Batch operations are the right tool for exactly one part of the audit: applying an operation to a large, enumerated list of objects with retries, throttling and a completion report. They are the wrong tool for the comparison itself, which is a query. Keeping that boundary clear is what stops the audit from becoming a distributed system.
Step-by-Step Procedure
Step 1 — Generate the manifest of read-required objects
The manifest is a CSV of bucket and key, produced by the same query that runs the routine audit — filtered to objects the inventory cannot vouch for.
-- Objects with no platform checksum: this quarter's batch workload
UNLOAD (
SELECT 'spatial-archive' AS bucket, m.object_key AS key
FROM archive_manifest m
JOIN spatial_archive_inventory i ON i.key = m.object_key
WHERE m.audit_slice = 2 AND i.checksum_algorithm IS NULL
)
TO 's3://spatial-archive-logs/audit/slice-2-manifest/'
WITH (format = 'TEXTFILE', field_delimiter = ',', compression = 'NONE');
Step 2 — Write the per-object function
Batch operations invoke a function once per object. It restores when needed, hashes the bytes as they stream, and writes the checksum back so this object leaves the read-required set for good.
# lambda_function.py — invoked once per object by S3 Batch Operations
import hashlib, json, boto3, urllib.parse
s3 = boto3.client("s3")
ddb = boto3.client("dynamodb")
COLD = {"GLACIER", "DEEP_ARCHIVE"}
def handler(event, context):
task = event["tasks"][0]
bucket, key = task["s3BucketArn"].split(":::")[-1], urllib.parse.unquote(task["s3Key"])
head = s3.head_object(Bucket=bucket, Key=key)
if head.get("StorageClass") in COLD and "ongoing-request=\"false\"" not in head.get("Restore", ""):
s3.restore_object(Bucket=bucket, Key=key,
RestoreRequest={"Days": 7, "GlacierJobParameters": {"Tier": "Bulk"}})
return _result(task, "TemporaryFailure", "restore requested; retry next run")
digest = hashlib.sha256()
body = s3.get_object(Bucket=bucket, Key=key)["Body"]
for chunk in iter(lambda: body.read(8 * 1024 * 1024), b""): # stream: never buffer a 4 GB scene
digest.update(chunk)
observed = digest.hexdigest()
expected = ddb.get_item(TableName="archive_manifest",
Key={"object_key": {"S": key}})["Item"]["sha256"]["S"]
if observed != expected:
return _result(task, "PermanentFailure", f"MISMATCH expected={expected} observed={observed}")
# Record the checksum on the object so it is verifiable from metadata from now on
s3.copy_object(Bucket=bucket, Key=key, CopySource={"Bucket": bucket, "Key": key},
ChecksumAlgorithm="SHA256", MetadataDirective="COPY")
return _result(task, "Succeeded", "verified and checksum recorded")
def _result(task, code, msg):
return {"invocationSchemaVersion": "1.0", "treatMissingKeysAs": "PermanentFailure",
"invocationId": task.get("invocationId", ""),
"results": [{"taskId": task["taskId"], "resultCode": code, "resultString": msg}]}
Returning TemporaryFailure for objects that need a restore is what makes the job self-pacing: the service retries them on the next run, by which time the bulk restore has completed.
Step 3 — Submit and schedule the job
aws s3control create-job --account-id 123456789012 \
--operation '{"LambdaInvoke":{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:archive-fixity"}}' \
--manifest '{"Spec":{"Format":"S3BatchOperations_CSV_20180820","Fields":["Bucket","Key"]},
"Location":{"ObjectArn":"arn:aws:s3:::spatial-archive-logs/audit/slice-2-manifest/part-0000",
"ETag":"3f9c…"}}' \
--report '{"Bucket":"arn:aws:s3:::spatial-archive-logs","Format":"Report_CSV_20180820",
"Enabled":true,"Prefix":"audit/slice-2","ReportScope":"AllTasks"}' \
--priority 5 --role-arn arn:aws:iam::123456789012:role/archive-batch --region us-east-1
Run it on a schedule with the slice number rotating, so the four quarterly runs cover the archive in a year.
Validation & Verification
Two assertions make the job trustworthy: that it processed every object in the manifest, and that it would have reported a mismatch had one existed.
# Every manifest line accounted for in the completion report
wc -l < slice-2-manifest.csv
aws s3 cp s3://spatial-archive-logs/audit/slice-2/job-<id>/results/ - --recursive | wc -l
# Canary: a deliberately altered object must land as a PermanentFailure
grep -c 'MISMATCH' results.csv
Expected output is equal line counts and a canary count of at least one. A completion report shorter than the manifest means tasks were dropped rather than failed, which no result code will tell you.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Function times out on large scenes | Buffering the whole object in memory | Stream in chunks as shown; raise the timeout to match the largest object |
| Job stalls at a fixed percentage | Restore quota reached for the account | Lower job priority and split the slice; bulk restores are rate-limited |
| Every task fails with access denied | Batch role lacks s3:GetObject on the prefix |
Grant it explicitly — the batch role is separate from the pipeline role |
| Checksum write-back creates new versions | copy_object on a versioned bucket always does |
Expected; the bytes are identical, and lifecycle should expire noncurrent versions |
| Mismatches cluster in one prefix | A processing job overwrote the objects | Triage before restoring: this is an overwrite, not corruption |
Operational Execution Checklist
Scheduling Against the Rest of the Archive’s Work
The audit competes for the same restore quota, request budget and network capacity as ingest, replication and user restores. Left unscheduled it will occasionally collide with all three, and the collision usually manifests as someone else’s job failing.
Give the batch job the lowest priority in the account so it defers to a user restore that arrives mid-run, and size each slice so it completes inside the window rather than spilling into Monday’s ingest. An audit that finishes late is not a problem; an audit that starves the replication of restore quota is.
Frequently Asked Questions
Why not just run this on a fleet of workers instead?
Because the hard parts are retries, throttling, and per-object reporting at scale, and the batch service provides all three. A bespoke fleet reproduces them badly, and its failure modes — a worker dying with tasks in flight, a partial manifest, an unbounded retry storm against a restore quota — are exactly what the service handles. Keep the bespoke code to the per-object logic.
How much does a full pass cost?
Almost entirely in restores and requests rather than compute. A slice of 40,000 cold objects averaging 250 MB costs roughly $25 in bulk retrieval, $10 in requests, and single-digit dollars of function time. The number falls every pass as objects gain recorded checksums and leave the read-required set.
Should the audit run against the replica too?
Yes, on its own schedule and against the same manifest values. Auditing only the primary leaves the copy you would recover from unverified, which is the wrong copy to be surprised by. Because the replica is usually colder, its pass leans more heavily on the batch side, which is another reason to enable additional checksums on the replication path.
What should the job do when the manifest is larger than the batch limit?
Split it and submit several jobs, one per shard, with the same priority. The service caps manifest size, and a shard that fails can be resubmitted without re-running the others. Sharding also makes progress legible — five completion reports arriving through the weekend is a much better signal than one job that either finishes or does not.
Can the same job verify the replica?
Not the same invocation, because the manifest names a bucket. Run a parallel job against the replica’s inventory with the same per-object function and the same expected checksums from the manifest, which is what makes the two verifications independent. Sharing the function is the point; sharing the job is not possible and would not be desirable.
How are results fed back into the audit’s own records?
Write them from the function rather than parsing the completion report. The report is a per-task record designed for retries, not an audit trail, and it expires with the job. Recording each verification result against the object — verified at, by which pass, with which outcome — is what lets the next pass skip recently verified objects and what gives the annual report its coverage figure.
Can the job’s priority be raised if a pass is running late?
It can, and it usually should not. The audit has no deadline, and raising its priority puts it in contention with work that does. A pass that slips into the following week costs nothing; one that delays a user restore costs goodwill that the programme depends on.
Related
- Archive Integrity Verification for Spatial Data — the parent topic and the design this job implements one part of.
- Verifying Checksums Across Spatial Archive Tiers — the query-side pass that decides what this job has to read.
- Detecting Bit Rot in Long-Term Raster Archives — what to do with the mismatches this job reports.
- Replicating Glacier-Class Spatial Objects Across Regions — the same restore-wave pattern applied to replication.
Up one level: Archive Integrity Verification.