fix(artifacts): honor the "user:" namespace in InMemoryArtifactService - #1449
fix(artifacts): honor the "user:" namespace in InMemoryArtifactService#1449shoemoney wants to merge 1 commit into
Conversation
GcsArtifactService treats a "user:"-prefixed filename as scoped to the user across all sessions: fileHasUserNamespace() (GcsArtifactService.java:65) makes getBlobPrefix() (GcsArtifactService.java:78-84) store at appName/userId/user/filename/version, deliberately leaving sessionId out. This is a documented convention and is covered by GcsArtifactServiceTest#save_userNamespace_savesCorrectly and #load_userNamespace_loadsCorrectly. InMemoryArtifactService had no equivalent branch. getArtifactsMap always keyed by appName -> userId -> sessionId -> filename, so a "user:" artifact saved in one session was invisible from another session for the same app/user, with no error - just an empty result. Since InMemoryArtifactService is the default backend for local dev, tests, and quickstarts, this meant the documented cross-session behavior silently broke the moment someone swapped in the in-memory service instead of GCS. Mirror the GCS branch: getArtifactsMap now takes the filename and routes user-namespaced ones to a shared per-user bucket instead of the session's, using the same fileHasUserNamespace() check and naming as GcsArtifactService. listArtifactKeys merges that shared bucket with the session-scoped one, matching GcsArtifactService#listArtifactKeys, which merges its session-prefix and user-prefix listings. Added tests mirroring GcsArtifactServiceTest's user-namespace cases for save/load, listArtifactKeys, listVersions, and deleteArtifact, plus a regression guard proving a non-prefixed filename saved in one session stays invisible from another - i.e. this fix scopes only the "user:" case and does not make all artifacts global.
|
Hi @shoemoney, thank you for taking the time to submit your PR. It is currently under review by our team and we will keep you updated if any additional information is required. Thank you. |
| ListArtifactsResponse.builder() | ||
| .filenames(ImmutableList.copyOf(getArtifactsMap(appName, userId, sessionId).keySet())) | ||
| .build()); | ||
| ListArtifactsResponse.builder().filenames(ImmutableList.copyOf(filenames)).build()); |
There was a problem hiding this comment.
Python ends list_artifact_keys with return sorted(filenames). ImmutableList.copyOf over a HashSet gives arbitrary order instead. Additionally, Java's own GcsArtifactService already sorts (:186), so the two backends currently disagree.
How about ListArtifactsResponse.builder().filenames(ImmutableList.sortedCopyOf(filenames)).build()); ?
| @Test | ||
| public void listArtifactKeys_userNamespace_visibleAcrossSessions() { | ||
| Part artifact = Part.fromBytes(new byte[] {9, 9, 9}, "application/json"); | ||
|
|
||
| var unused = | ||
| service.saveArtifact(APP_NAME, USER_ID, SESSION_A, USER_FILENAME, artifact).blockingGet(); | ||
|
|
||
| ListArtifactsResponse response = | ||
| service.listArtifactKeys(APP_NAME, USER_ID, SESSION_B).blockingGet(); | ||
|
|
||
| assertThat(response.filenames()).contains(USER_FILENAME); | ||
| } | ||
|
|
There was a problem hiding this comment.
This test passes when the list would be full of ghosts so it might be worth adding one more, proving a failed load leaves nothing behind
@Test
public void listArtifactKeys_failedUserNamespaceLoad_doesNotCreatePhantomKey() {
Optional<Part> missing =
asOptional(service.loadArtifact(APP_NAME, USER_ID, SESSION_A, USER_FILENAME));
assertThat(missing).isEmpty();
ListArtifactsResponse response =
service.listArtifactKeys(APP_NAME, USER_ID, SESSION_B).blockingGet();
assertThat(response.filenames()).isEmpty();
}
and possibly similarly for listVersions?
| String appName, String userId, String sessionId, String filename, @Nullable Integer version) { | ||
| List<Part> versions = | ||
| getArtifactsMap(appName, userId, sessionId) | ||
| getArtifactsMap(appName, userId, sessionId, filename) |
There was a problem hiding this comment.
In a read path computeIfAbsent writes an empty entry on a miss, and listArtifactKeys reports keySet(). So a failed load invents a file. For reference, Python's read is pure (self.artifacts.get(path)).
This is a pre-existing issue — except this PR changes its reach. On main the ghost stays in the caller's session; now user: files share one bucket, so every session of that user sees it:
loadArtifact(..., sessionA, "user:missing.json") -> empty
listArtifactKeys(..., sessionB) -> ["user:missing.json"] (main: [])
What do you think of going back to a pure read, ImmutableList.of() is a shared singleton, so it allocates nothing
List<Part> versions =
getArtifactsMap(appName, userId, sessionId, filename)
.getOrDefault(filename, ImmutableList.of());
| int size = | ||
| getArtifactsMap(appName, userId, sessionId) | ||
| getArtifactsMap(appName, userId, sessionId, filename) | ||
| .computeIfAbsent(filename, unused -> new ArrayList<>()) | ||
| .size(); |
There was a problem hiding this comment.
I checked and this one leaks across sessions as well. Python's list_versions is also a pure read. Same fix could apply:
int size =
getArtifactsMap(appName, userId, sessionId, filename)
.getOrDefault(filename, ImmutableList.of())
.size();
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
1. Link to an existing issue (if applicable):
None found.
2. Or, if no issue exists, describe the change:
Problem:
ADK has a
"user:"filename-prefix convention: a filename starting withuser:is scoped to the user across all their sessions, not just the session it was saved from.GcsArtifactServiceimplements this.fileHasUserNamespace(filename)(GcsArtifactService.java:65) checks the prefix, andgetBlobPrefix(GcsArtifactService.java:78-84) branches on it to store atappName/userId/user/filename/version, deliberately leaving the sessionId segment out. This is exercised directly inGcsArtifactServiceTest(save_userNamespace_savesCorrectly,load_userNamespace_loadsCorrectly).InMemoryArtifactServicehad no equivalent branch. ItsgetArtifactsMap(appName, userId, sessionId)always keyed byappName -> userId -> sessionId -> filename, and every public method (saveArtifact,loadArtifact,listArtifactKeys,deleteArtifact,listVersions) went through it. So if code calledCallbackContext.saveArtifact("user:config.json", part)in one session, thenloadArtifact("user:config.json")in a different session for the same app/user, it gotMaybe.empty(). No exception, just silently missing data.This matters because
InMemoryArtifactServiceis the default backend used in local dev, tests, and the quickstarts. An app can be written and tested entirely againstInMemoryArtifactService, rely on the documented cross-session behavior ofuser:-prefixed filenames, and only discover it never worked once it's pointed atGcsArtifactServicein prod, or never discover it at all if it stays onInMemoryArtifactService.Solution:
Mirror the GCS branch.
getArtifactsMapnow also takes the filename, using the samefileHasUserNamespacecheck and naming asGcsArtifactService, and routes user-namespaced filenames to a shared per-user bucket (USER_NAMESPACE_SESSION_KEY) instead of the caller'ssessionId.saveArtifact,loadArtifact,deleteArtifact, andlistVersionsall pass the filename through unchanged.listArtifactKeysdoesn't have a filename to branch on, so it now merges the session-scoped bucket with the shared user-namespace bucket, which is the same thingGcsArtifactService#listArtifactKeysdoes (it merges a session-prefix listing with a user-prefix listing).No behavior changes for non-
user:-prefixed filenames.GcsArtifactServiceis untouched.Testing Plan
Unit Tests:
Added to
InMemoryArtifactServiceTest, mirroring the shape of theGcsArtifactServiceTestuser-namespace cases:save_userNamespace_visibleAcrossSessions- save from session A, load from session B, same value comes back.load_nonNamespacedFilename_notVisibleAcrossSessions- regression guard. A non-prefixed filename saved in session A must still be invisible from session B. This is what proves the fix scopes only theuser:case instead of making every artifact global.listArtifactKeys_userNamespace_visibleAcrossSessionslistVersions_userNamespace_visibleAcrossSessionsdeleteArtifact_userNamespace_removesAcrossSessionsI ran
InMemoryArtifactServiceTestagainst the unpatched source first to confirm these actually catch the bug:load_nonNamespacedFilename_notVisibleAcrossSessionsand the three pre-existing tests passed in both states, as expected.With the fix applied:
Also ran
GcsArtifactServiceTestto confirm no regression there, since it's untouched:Manual End-to-End (E2E) Tests:
Not applicable, this is contained to
InMemoryArtifactService's in-process map and is fully covered by the unit tests above.Checklist
Additional context
I checked the other two open PRs that touch artifacts, #1378 and #1412. Both are about
Runner/plugin-level input-blob offload and don't touchInMemoryArtifactServiceor the user-namespace behavior, so there's no overlap with this change.