aws-amplify / aws-amplify/amplify-android
API: fetchModel's callback fires twice for an already-loaded lazy reference
- Dominant language
- Java
- Stars
- 287
- Forks
- 132
- Avg merge
- 2d 2h
- Merged PRs (30d)
- 45
Description
### Summary
`ApiLazyModelReference.fetchModel(onSuccess, onError)` delivers `onSuccess` **twice** whenever the value is already cached — that is, on every call after the first. A comment says "quick return" but the code does not return.
Found while implementing the same behaviour elsewhere, not by review.
### Where
`aws-api/src/main/java/com/amplifyframework/api/aws/ApiLazyModelReference.kt`
```kotlin
override fun fetchModel(onSuccess: NullableConsumer, onError: Consumer) {
val cached = cachedValue.get()
if (cached != null) {
// Quick return if value is already present
onSuccess.accept(cached.value) // <-- no return
}
callbackScope.launch {
try {
val model = fetchInternal() // <-- re-reads the same cache, returns immediately
onSuccess.accept(model) // <-- delivers the same value a second time
} catch (e: AmplifyException) {
onError.accept(e)
}
}
}
```
`fetchInternal()` starts by checking `cachedValue` and returning early, so the coroutine resolves straight away with the identical value and invokes `onSuccess` again.
### When it happens
Any `fetchModel(onSuccess, onError)` call once the value is loaded. Three ways to get there:
- A second `fetchModel` call on the same reference — the first populated the cache.
- A first call on a reference whose key map was empty. `init` seeds a loaded `null` in that case, so the very first call double-fires.
- Interleaving with the suspend `fetchModel()`, which populates the same cache.
### Impact
No duplicate network request — `fetchInternal` short-circuits — so this is not a load or throttling problem. The cost is entirely in the consumer: a callback that appends to a list, increments a counter, emits to a stream, or triggers a UI refresh does it twice for one request. A Java consumer using the callback API is the intended audience for this overload, so it is the path least likely to notice a second invocation and most likely to be doing something non-idempotent in it.
### Scope
Isolated to `ApiLazyModelReference`. I checked the sibling: `ApiLazyModelList.fetchPage`'s two callback overloads have no cache and no early-delivery path, so they only ever launch once. No change needed there.
### Why the existing tests do not catch it
`ApiLazyModelReferenceTest` asserts on **query counts**, never on callback counts — every assertion is of the form:
```kotlin
verify(exactly = 1) { apiCategory.query(expectedApi, any(), any>>(), any()) }
```
Those assertions are correct and they pass, because the query count *is* right: `fetchInternal` short-circuits, so only one request is ever made. The defect is on a different axis from the one the tests measure. A test suite can be green, thorough about caching, and still blind to this.
### Suggested fix
```kotlin
val cached = cachedValue.get()
if (cached != null) {
onSuccess.accept(cached.value)
return
}
```
### Suggested regression test
Count the callback rather than the query:
```kotlin
@Test
fun `a cached value is delivered to the callback exactly once`() {
val reference = /* reference whose value is already loaded */
var deliveries = 0
reference.fetchModel(NullableConsumer { deliveries++ }, Consumer { fail("unexpected error") })
// Fails before the fix with deliveries == 2.
deliveries shouldBe 1
}
```
The empty-key-map case is worth a second test, since it double-fires on the *first* call and needs no priming.
### Environment
Observed by reading `main` at `VERSION_NAME=2.41.1`. Not reproduced against a live endpoint — the analysis is from the source and the cache short-circuit in `fetchInternal`.
Contributor guide
Research direction
Start in aws-api/src/main/java/com/amplifyframework/api/aws/ApiLazyModelReference.kt, focusing on the callback fetchModel overload and its cached-value path. Read ApiLazyModelReferenceTest and add callback-delivery coverage for an already-loaded value and an empty key map. Run that test; done means each callback request invokes onSuccess exactly once without increasing query counts.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- android, kotlin
- Domain
- api, mobile
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100