Skip to content

cold-store-reaper: an offset-less LastModified in an S3 listing parses to a naive datetime and crashes backup_supabase / evict_scan — _parse_s3_list_page promises aware-or-None #2110

Closed

Description

@dezren39

cold-store-reaper: an offset-less LastModified in an S3 listing parses to a naive datetime and crashes backup_supabase / evict_scan

Evidence (main 82b019a0)

pkgs/cold-store-reaper/cold_store.py:138-165 — _parse_s3_list_page documents its own contract:

Each object is {key, size, last_modified}; last_modified is an aware datetime, or None when absent/unparseable — callers treat None as oldest, never as 'now'.

The parse at :162 breaks that contract:

stamp = datetime.fromisoformat(raw.replace("Z", "+00:00"))

An ISO-8601 stamp with no timezone designator (e.g. 2026-10-01T00:00:00.000 — a form S3-compatible stores emit; this module already anticipates non-AWS reply variation, cf. the namespace note in the same docstring) parses successfully to a naive datetime — not aware, not None. Every downstream consumer assumes aware:

  • cold_store.py:1780 (backup_supabase): max(objects, key=lambda o: (o["last_modified"] or epoch, o["key"])) with epoch aware — comparing a naive stamp against the aware epoch (or another object's aware stamp) raises TypeError: can't compare offset-naive and offset-aware datetimes.
  • cold_store.py:1837 (evict_scan): the same tuple sort over the whole bucket.
  • cold_store.py:1861 (evict_scan): now - obj["last_modified"] with now aware raises TypeError: can't subtract offset-naive and offset-aware datetimes for any naive-stamped object that reaches the age check.

Reproduction (verified this run, shipped module imported unmodified)

_parse_s3_list_page(<Contents>…<LastModified>2026-10-01T00:00:00.000</LastModified>…)
  -> last_modified = 2026-10-01 00:00:00, tzinfo = None        # contract says aware-or-None
datetime.now(timezone.utc) - stamp
  -> TypeError: can't subtract offset-naive and offset-aware datetimes
sorted(objects, key=lambda o: (o["last_modified"] or aware_epoch, o["key"]))
  -> TypeError: can't compare offset-naive and offset-aware datetimes

Why it matters

One offset-less stamp anywhere in a bucket listing takes down the whole backup_supabase run and the whole eviction scan with an unhandled TypeError — no object is filed, no verdict is produced, and the traceback names datetime internals rather than the offending key. The configured R2 backend currently emits Z stamps, so this is latent until the tool is pointed at (or proxied/stubbed by) a store that omits the offset — exactly the variation this parser already defends against elsewhere. The fix direction is triage's pin: treat an offset-less stamp as unparseable (→ None, the documented safe meaning: oldest / never age-eligible) or interpret it as UTC — the two differ in eviction age verdicts, so the choice should be explicit.

Acceptance criteria

  • _parse_s3_list_page upholds its documented contract for every input: last_modified is always an aware datetime or None (offset-less stamps handled by the pinned rule, with a comment naming it).
  • backup_supabase and evict_scan complete without TypeError on a listing mixing aware, offset-less, and missing LastModified values.
  • Tests cover: offset-less stamp page; a mixed listing through evict_scan (the offset-less object gets a non-evictable verdict, never a crash) and through the backup_supabase newest pick.
  • Existing behaviour unchanged for Z / explicit-offset stamps and for absent LastModified.

Dedup

Searched open + closed issues for cold-store, s3_list, LastModified, naive, evict_scan: #1632 (open) is the continuation-token loop in the same listing function — a different defect with a different fix; #1427/#1428/#1430/#1435 concern CI, shell plumbing, env validation and test speed. No issue covers offset-less timestamp parsing.

Activity

  1. developing-today commented on Oct 29, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/lowNice to have; do when convenient
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/cold-store-reaperPackage/app: cold-store-reaper (pkgs/ or apps/ in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 29, 2026
  3. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (lane A, new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a run 2026-10-29T04:16Z
    • Found via: filed this run
    • Task: Filed after verifying on main 82b019a that cold_store.py _parse_s3_list_page (:162) parses an offset-less LastModified to a naive datetime despite its documented aware-or-None contract (:143), crashing backup_supabase (:1780 max) and evict_scan (:1837 sort, :1861 age subtraction) with TypeError; enqueued a triage packet.
    • Expectation: effort/xs — triage pins offset-less = unparseable (None) vs interpret-as-UTC (differs in eviction age verdicts), then routes a small_fix packet.
    • Links: packet queue-pilot/packet-2110-triage.json (repo-health-code-pilot)
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · TRIAGE VERDICT: CONFIRMED — reproduced on main 5a34fa70 (openchamber scheduled lane, 2026-10-29T21:54Z)

    Independently reproduced against the shipped module imported unmodified from a clean worktree at origin/main. The issue is accurate, and the reproduction adds one refinement about which consumer crashes.

    Confirmed mechanism

    pkgs/cold-store-reaper/cold_store.py:162 — datetime.fromisoformat(raw.replace("Z", "+00:00")). An offset-less stamp parses successfully to a naive datetime, so the except ValueError: stamp = None safety net never fires and the function's own docstring contract ("aware datetime, or None") is broken at that line and nowhere else.

    Reproduction (verbatim output)

    == 1. offset-less stamp ==
      value: 2026-10-01 00:00:00 | tzinfo: None | aware: False
      CONTRACT VIOLATED (docstring: aware datetime or None)
    == 2. max() key as backup_supabase uses it ==
      TypeError: can't compare offset-naive and offset-aware datetimes
    == 3. sorted() key as evict_scan uses it ==
      TypeError: can't compare offset-naive and offset-aware datetimes
    == 4. age subtraction as evict_scan uses it ==
      k0 age: 28 days, 21:48:12.969851
      k1 TypeError: can't subtract offset-naive and offset-aware datetimes
    == 5. naive-but-old, alone in the listing ==
      sorted ok: ['k0']
      TypeError: can't subtract offset-naive and offset-aware datetimes
    == 6. Z and explicit-offset stamps unchanged ==
       ['2026-10-01T00:00:00+00:00', '2026-10-01T05:00:00+05:00', None]
    

    Refinement the issue states too broadly — worth pinning in the fix

    The two failure modes have different trigger conditions, and the fix's tests should cover both:

    1. Sort/newest-pick (sorted in evict_scan, max in backup_supabase) crashes only when a naive stamp is compared against an aware one — i.e. a mixed listing, or a listing where a neighbour is absent (absent → None or epoch → aware). A listing where every stamp is offset-less sorts fine (case 5). So "one offset-less stamp takes down the scan" is true only when the listing is not uniformly offset-less.
    2. Age subtraction (now - obj["last_modified"], cold_store.py:1861) crashes on any single naive stamp that reaches the age check, with no neighbour required (case 5). This is the unconditional crash: one offset-less, hash-known, manifest-cold object is enough to kill the whole eviction scan.

    backup_supabase is therefore the narrower exposure (mixed listing only) and evict_scan the wider one.

    Pinned rule (the choice the issue left open)

    Treat an offset-less stamp as unparseable → None. Reasons:

    • It is the module's existing documented safe meaning ("callers treat None as oldest, never as 'now'"), so the fix restores a contract rather than introducing a second one.
    • Eviction deletes objects. Acting on a timestamp whose zone we cannot name is exactly the case where the conservative answer must win; None yields too-young, which is non-evictable by construction.
    • It matches this issue's own acceptance criterion ("the offset-less object gets a non-evictable verdict, never a crash"), which the "interpret as UTC" alternative would contradict.

    Implementation is a one-line guard plus a comment naming the rule, e.g. if stamp.tzinfo is None: stamp = None # offset-less = zone unknown = unparseable (contract: aware-or-None) — after the existing try/except ValueError.

    Test matrix the fix should carry

    • offset-less page → None (contract test, currently the failing one)
    • mixed listing (aware + offset-less + absent) through evict_scan → completes, offset-less object too-young, never TypeError
    • mixed listing through backup_supabase's newest pick → completes, newest is an aware-stamped object
    • uniformly offset-less listing → no crash in the age check (the case 5 trap; a sort-only test would pass while the scan still died)
    • unchanged: Z, explicit-offset, and absent LastModified

    Scope note

    Triage only — this lane changed nothing: no branch, no PR. The worktree used for the repro has been removed. #1632 (continuation-token loop, same function) remains a separate defect; the fix here should not touch the token path.

  5. added
    status/triagedTriaged and routed (see agent note)
    task/small-fixQueue packet kind: bounded fix with reproduction and targeted tests
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    task/triageQueue packet kind: verify, reproduce, and route this item
    queue/queuedHas a queued task in the Supabase agent queue
    status/triagedTriaged and routed (see agent note)
    on Oct 29, 2026
  6. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. added a commit that references this issue on Oct 30, 2026
  8. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

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

Metadata

Metadata

Assignees

Labels

app/cold-store-reaperPackage/app: cold-store-reaper (pkgs/ or apps/ in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/lowNice to have; do when convenientsource/creatorFiled by the new-issue-creator jobtask/small-fixQueue packet kind: bounded fix with reproduction and targeted tests

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions