Skip to content

perf(artifacts): serve the library listing from UserArtifactsIndex - #989

Merged
philmerrell merged 1 commit into
developfrom
feature/artifact-library-user-index-query
Sep 7, 2026
Merged

perf(artifacts): serve the library listing from UserArtifactsIndex#989
philmerrell merged 1 commit into
developfrom
feature/artifact-library-user-index-query

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Completes the index work. list_for_user now queries UserArtifactsIndex (GSI2PK=USER#{uid}, GSI2SK descending) instead of the base table.

Safe to merge: the index is ACTIVE in dev and verified populated — see below. Do not merge to main until the same is confirmed there.

What it changes

HEAD and version rows share the base partition, so the old Query spanned ~3× the rows it returned and then date-sorted them in memory. Only HEAD rows carry the GSI2 keys, so the index holds one row per artifact already newest-first. The amplification and the sort both go away, and ordering comes from the store rather than being recomputed per request.

Still returns the whole library in one response, paging the index internally. Exposing pagination is a bigger change than it looks — search and the type filter live in the SPA, and a filter that sees only the loaded page is worse than no filter because it looks authoritative, so both would move server-side in the same change. The index makes that possible whenever it's wanted.

Verified against the live dev index

UserArtifactsIndex   ACTIVE   Backfilling: none
indexed rows: 22                 (= the 22 HEAD rows the backfill stamped)
one user's partition: 9 rows     (= the "9 of 9" the library page shows)
descending by updated_at: True
all rows are #HEAD: True

ItemCount still reads 0 in DescribeTable — that figure refreshes about every 6 hours, so it's stale metadata, not truth. The scan and query above are the real check.

Two bugs this turned up

The library tests were passing against the old code path. My first splice used inverted slice boundaries and left duplicate list_for_user and heads_for_session definitions — Python takes the last, which was the original base-table version. The suite went green while testing nothing new. I caught it by asking why tests that should need a GSI passed against a fixture that had none.

The fixture now declares the index, so moto raises ResourceNotFoundException if the query ever stops using it. That's what makes these tests exercise the index instead of silently falling back.

Undated rows would have vanished. A sparse index omits any HEAD row without GSI2PK, permanently and silently. Rows predating updated_at can't carry a real timestamp, so the first version of the backfill skipped them — and they'd have dropped out of their owner's library, which test_undated_legacy_rows_are_returned_and_sort_last exists to forbid.

They're now stamped with an empty timestamp segment (ARTIFACT##{aid}). That isn't a fabricated time: # sorts below every digit, so read descending the row lands last — exactly where the old in-memory sort put it. Neither dev nor prod holds such a row today; this is the defensive branch preserving a contract the tests already assert.

The pagination stub now yields the newer row on page 1, matching a descending index. With the old stub a client-side re-sort would have passed either way and hidden a broken sort key.

Tests

942 passed across app_api, architecture and the artifact writer suites.

🤖 Generated with Claude Code

⚠️ MUST NOT MERGE until `UserArtifactsIndex` reports ACTIVE in the
target environment. `DescribeTable`, not CloudFormation — CFN reports
UPDATE_COMPLETE while DynamoDB is still backfilling the index.

`list_for_user` now queries the index (GSI2PK=USER#{uid}, GSI2SK
descending) instead of the base table. HEAD and version rows share the
base partition, so the old Query spanned roughly 3x the rows it returned
and then date-sorted them in memory; only HEAD rows carry the GSI2 keys,
so the index holds one row per artifact already newest-first. The
amplification and the sort both go away, and the ordering now comes from
the store rather than being recomputed per request.

Still returns the whole library in one response, paging the index
internally. Exposing pagination is a bigger change than it looks —
search and the type filter live in the SPA, and a filter that sees only
the loaded page is worse than no filter because it looks authoritative,
so both would have to move server-side in the same change. The index
makes that possible whenever it is wanted.

## Two things this turned up

**The library tests were passing against the old code path.** My first
edit spliced the new method in with inverted slice boundaries, leaving
DUPLICATE `list_for_user` and `heads_for_session` definitions — Python
took the last, which was the original base-table version. The suite went
green while testing nothing new. Caught by asking why tests that should
have needed a GSI passed without one; the fixture had no
GlobalSecondaryIndexes at all.

The fixture now declares the index, so moto raises
ResourceNotFoundException if the query ever stops using it — which is
what makes these tests exercise the index rather than silently falling
back.

**Undated rows would have vanished.** A sparse index omits any HEAD row
without GSI2PK, permanently and silently. Rows predating `updated_at`
cannot carry a real timestamp, so under the first version of the
backfill they were skipped — and would have dropped out of their
owner's library, which `test_undated_legacy_rows_are_returned_and_sort_last`
exists to forbid.

They are now stamped with an EMPTY timestamp segment
(`ARTIFACT##{aid}`). That is not a fabricated time: "#" sorts below
every digit, so read descending the row lands last — exactly where the
old in-memory sort put it. Neither dev nor prod holds such a row today;
this is the defensive branch, and it preserves a contract the tests
already assert.

The pagination stub now yields the NEWER row on page 1, matching what a
descending index does. With the old stub a client-side re-sort would
have passed either way and hidden a broken sort key.

Backend: 942 passed across app_api, architecture and the artifact
writer suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell philmerrell mentioned this pull request Sep 6, 2026
@philmerrell
philmerrell merged commit 720f142 into develop Sep 7, 2026
4 checks passed
@philmerrell
philmerrell deleted the feature/artifact-library-user-index-query branch September 7, 2026 03:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant