Skip to content

AWS: garbageCollect permanently wedged — DeleteObjects called with an empty key list returns MalformedXML, GCCoord.fromSize never advances #1179

Description

@nkreiger

Summary

On the AWS (S3 + MySQL) backend, garbageCollect is permanently wedged: every invocation fails with MalformedXML from s3:DeleteObjects, GCCoord.fromSize is never advanced, and no partial tile or entry bundle is ever collected. Because the failure is deterministic and fromSize never moves, the log retries the identical range forever and obsolete partials accumulate without bound.

We hit this on a production log running rekor-tiles v2.3.0 (tessera v1.0.2). It has failed every 60s since the log was created, and has collected exactly zero objects. The same code is present unchanged in v1.0.3, v1.0.4 and main.

Symptom

level=INFO msg="GarbageCollect failed: failed to delete one or more objects: failed to delete objects:
operation error S3: DeleteObjects, https response error StatusCode: 400,
api error MalformedXML: The XML you provided was not well-formed or did not validate against our published schema"

Root cause

deleteObjectsWithPrefix calls DeleteObjects unconditionally, even when ListObjectsV2 matched nothing:

l, err := s.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
    Bucket: aws.String(s.bucket),
    Prefix: aws.String(objPrefix),
})
...
di := &s3.DeleteObjectsInput{
    Bucket: aws.String(s.bucket),
    Delete: &types.Delete{
        Objects: make([]types.ObjectIdentifier, 0, len(l.Contents)),
    },
}
for _, k := range l.Contents { ... }
if _, err := s.s3Client.DeleteObjects(ctx, di); err != nil {   // <-- empty Objects => MalformedXML
    return fmt.Errorf("failed to delete objects: %v", err)
}

The S3 DeleteObjects API requires Delete.Objects to contain at least one element. An empty list is rejected with 400 MalformedXML.

garbageCollect calls this with .p/ prefixes for every in-scope bundle:

eg.Go(func() error { return deleteWithPrefix(ctx, entriesPath(ri.Index, 0)+".p/") })
eg.Go(func() error { return deleteWithPrefix(ctx, layout.TilePath(0, ri.Index, 0)+".p/") })

A bundle or tile that was only ever written in its full form has no .p/ objects, so that prefix matches nothing and the call fails.

Note this is not the more familiar ">1000 keys per DeleteObjects" variant of MalformedXML. ListObjectsV2 here is unpaginated (no ContinuationToken loop), so l.Contents can never exceed 1000 and the batch cannot overflow. The empty list is the only way this call produces MalformedXML.

Why it never recovers

In garbageCollect, the progress write happens after the errgroup:

if err := eg.Wait(); err != nil {
    return fmt.Errorf("failed to delete one or more objects: %v", err)
}
if _, err := tx.ExecContext(ctx, "UPDATE GCCoord SET fromSize=? WHERE id=?", fromSize, 0); err != nil {

A single failing prefix returns early, the transaction rolls back, and GCCoord.fromSize is never updated. The next run recomputes the same range and fails on the same prefix. This makes a transient-looking error permanent.

Worse, the very first bundle is usually affected, so GC never makes any progress. On our log we probed the .p/ sibling prefix of ten known-full objects; nine were empty, including the index-0 entries:

prefix objects
tile/entries/000.p/ 0
tile/0/000.p/ 0
tile/entries/x001/000.p/ 0
tile/0/x001/000.p/ 0
tile/1/000.p/ 0
tile/2/000.p/ 3

With fromSize = 0, the first two prefixes GC touches are both empty, so it fails before it can advance past index 0.

Impact

Unbounded growth of obsolete partial tiles and entry bundles. Our log is at ~8.4M objects / ~690 GB, growing ~200-270k objects and ~20 GB/day, roughly half of which are uncollected .p/ partials.

There is no way to work around this from configuration — rekor-server serve exposes no GC-related flag, and an S3 lifecycle rule cannot distinguish a superseded partial tile from a live one.

Suggested fix

Return early when there is nothing to delete:

if len(l.Contents) == 0 {
    return nil
}

Two adjacent issues worth considering in the same change:

  1. Paginate ListObjectsV2. It currently returns at most 1000 keys with no ContinuationToken loop, so a prefix holding more than 1000 partials can never be fully collected.
  2. Let GC make partial progress. Since the deletes are idempotent, it may be preferable to advance GCCoord.fromSize past ranges that succeeded rather than discarding the whole run's progress when one prefix errors.

I'm happy to send a PR for the guard plus pagination if that's useful.

Environment

  • tessera v1.0.2 (also present unchanged in v1.0.3, v1.0.4, main)
  • via rekor-tiles v2.3.0, AWS driver (S3 + Aurora MySQL)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions