aws-amplify / aws-amplify/amplify-android

API: fetchModel's callback fires twice for an already-loaded lazy reference

Open Beginner friendly
#3,438 1 comment 0 reactions 0 assignees View on GitHub
api bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.