Skip to content

Commit fd26bd8

Browse files
committed
refactor(liveobjects): rename async future helpers and cover their propagation
Rename the DefaultRealtimeObject async bridges for clarity: asyncApi -> asyncFuture (generic, value-returning) and asyncVoidApi -> asyncVoidFuture (the Unit->Void adapter required by the Java write APIs), updating all call sites. Behaviour is unchanged; the thenApply { null } bridge stays because kotlinx's future builder cannot produce a CompletableFuture<Void> (its type param is non-null, Void's only value is null). Add DefaultRealtimeObjectAsyncTest asserting result and exception propagation for both helpers, and - crucially - that a failed operation does not cancel the shared sequential scope for subsequent operations, locking in the SupervisorJob failure-isolation guarantee.
1 parent 6b4f989 commit fd26bd8

6 files changed

Lines changed: 95 additions & 21 deletions

File tree

liveobjects/src/main/kotlin/io/ably/lib/liveobjects/DefaultRealtimeObject.kt

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,25 @@ internal class DefaultRealtimeObject(
7878
incomingObjectsHandler = initializeHandlerForIncomingObjectMessages()
7979
}
8080

81-
override fun get(): CompletableFuture<LiveMapPathObject> {
82-
throwIfInvalidAccessApiConfiguration() // RTO23a
83-
return asyncApi { getRootAsync() }
84-
}
85-
8681
/**
8782
* Runs [block] on the sequential scope and exposes it as a CompletableFuture. Failures
8883
* complete the future exceptionally with the original AblyException.
8984
*/
90-
internal fun <T> asyncApi(block: suspend () -> T): CompletableFuture<T> =
85+
internal fun <T> asyncFuture(block: suspend () -> T): CompletableFuture<T> =
9186
sequentialScope.future { block() }
9287

88+
/**
89+
* Runs a mutating [block] on the sequential scope, exposed as a CompletableFuture<Void>.
90+
* Used by the path/instance write APIs.
91+
*/
92+
internal fun asyncVoidFuture(block: suspend () -> Unit): CompletableFuture<Void> =
93+
asyncFuture(block).thenApply { null }
94+
95+
override fun get(): CompletableFuture<LiveMapPathObject> {
96+
throwIfInvalidAccessApiConfiguration() // RTO23a
97+
return asyncFuture { getRootAsync() }
98+
}
99+
93100
override fun on(event: ObjectStateEvent, listener: ObjectStateChange.Listener): Subscription {
94101
throwIfInvalidAccessApiConfiguration()
95102
return objectsManager.on(event, listener)
@@ -115,13 +122,6 @@ internal class DefaultRealtimeObject(
115122
return DefaultLiveMapPathObject(this, "")
116123
}
117124

118-
/**
119-
* Runs a mutating [block] on the sequential scope, exposed as a CompletableFuture<Void>.
120-
* Used by the path/instance write APIs.
121-
*/
122-
internal fun asyncVoidApi(block: suspend () -> Unit): CompletableFuture<Void> =
123-
asyncApi(block).thenApply { null }
124-
125125
/**
126126
* Spec: RTO14
127127
*/

liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveCounterInstance.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@ internal class DefaultLiveCounterInstance(
4141

4242
override fun increment(amount: Number): CompletableFuture<Void> {
4343
channelObject.throwIfInvalidWriteApiConfiguration() // RTINS14b
44-
return channelObject.asyncVoidApi { counter.increment(amount) } // RTINS14c -> RTLC12
44+
return channelObject.asyncVoidFuture { counter.increment(amount) } // RTINS14c -> RTLC12
4545
}
4646

4747
// RTINS15a1 - default amount of 1; delegates to decrement(Number)
4848
override fun decrement(): CompletableFuture<Void> = decrement(1)
4949

5050
override fun decrement(amount: Number): CompletableFuture<Void> {
5151
channelObject.throwIfInvalidWriteApiConfiguration() // RTINS15b
52-
return channelObject.asyncVoidApi { counter.decrement(amount) } // RTINS15c -> RTLC13
52+
return channelObject.asyncVoidFuture { counter.decrement(amount) } // RTINS15c -> RTLC13
5353
}
5454

5555
override fun subscribe(listener: InstanceListener): Subscription {

liveobjects/src/main/kotlin/io/ably/lib/liveobjects/instance/types/DefaultLiveMapInstance.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ internal class DefaultLiveMapInstance(
7171

7272
override fun set(key: String, value: LiveMapValue): CompletableFuture<Void> {
7373
channelObject.throwIfInvalidWriteApiConfiguration() // RTINS12b
74-
return channelObject.asyncVoidApi { map.set(key, value) } // RTINS12c -> RTLM20
74+
return channelObject.asyncVoidFuture { map.set(key, value) } // RTINS12c -> RTLM20
7575
}
7676

7777
override fun remove(key: String): CompletableFuture<Void> {
7878
channelObject.throwIfInvalidWriteApiConfiguration() // RTINS13b
79-
return channelObject.asyncVoidApi { map.remove(key) } // RTINS13c -> RTLM21
79+
return channelObject.asyncVoidFuture { map.remove(key) } // RTINS13c -> RTLM21
8080
}
8181

8282
override fun subscribe(listener: InstanceListener): Subscription {

liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveCounterPathObject.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ internal class DefaultLiveCounterPathObject(
3636
if (resolvedValue !is ResolvedValue.CounterRef) {
3737
throw typeMismatchError("Cannot increment a non-LiveCounter object at path: \"$path\"") // RTPO17e
3838
}
39-
return channelObject.asyncVoidApi { resolvedValue.counter.increment(amount) } // RTPO17d -> RTLC12
39+
return channelObject.asyncVoidFuture { resolvedValue.counter.increment(amount) } // RTPO17d -> RTLC12
4040
}
4141

4242
// RTPO18a1 - default amount of 1; delegates to decrement(Number)
@@ -48,6 +48,6 @@ internal class DefaultLiveCounterPathObject(
4848
if (resolvedValue !is ResolvedValue.CounterRef) {
4949
throw typeMismatchError("Cannot decrement a non-LiveCounter object at path: \"$path\"") // RTPO18e
5050
}
51-
return channelObject.asyncVoidApi { resolvedValue.counter.decrement(amount) } // RTPO18d -> RTLC13
51+
return channelObject.asyncVoidFuture { resolvedValue.counter.decrement(amount) } // RTPO18d -> RTLC13
5252
}
5353
}

liveobjects/src/main/kotlin/io/ably/lib/liveobjects/path/types/DefaultLiveMapPathObject.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ internal class DefaultLiveMapPathObject(
6767
if (resolvedValue !is ResolvedValue.MapRef) {
6868
throw typeMismatchError("Cannot set a key on a non-LiveMap object at path: \"$path\"") // RTPO15e
6969
}
70-
return channelObject.asyncVoidApi { resolvedValue.map.set(key, value) } // RTPO15d -> RTLM20
70+
return channelObject.asyncVoidFuture { resolvedValue.map.set(key, value) } // RTPO15d -> RTLM20
7171
}
7272

7373
override fun remove(key: String): CompletableFuture<Void> {
@@ -76,6 +76,6 @@ internal class DefaultLiveMapPathObject(
7676
if (resolvedValue !is ResolvedValue.MapRef) {
7777
throw typeMismatchError("Cannot remove a key from a non-LiveMap object at path: \"$path\"") // RTPO16e
7878
}
79-
return channelObject.asyncVoidApi { resolvedValue.map.remove(key) } // RTPO16d -> RTLM21
79+
return channelObject.asyncVoidFuture { resolvedValue.map.remove(key) } // RTPO16d -> RTLM21
8080
}
8181
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.ably.lib.liveobjects.unit
2+
3+
import io.ably.lib.liveobjects.DefaultRealtimeObject
4+
import io.ably.lib.types.AblyException
5+
import io.ably.lib.types.ErrorInfo
6+
import io.mockk.unmockkAll
7+
import org.junit.After
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertNull
10+
import org.junit.Assert.assertSame
11+
import org.junit.Test
12+
import java.util.concurrent.ExecutionException
13+
import java.util.concurrent.TimeUnit
14+
import kotlin.test.assertFailsWith
15+
16+
/**
17+
* Unit tests for [DefaultRealtimeObject.asyncFuture] / [DefaultRealtimeObject.asyncVoidFuture]:
18+
* result and exception propagation, plus the SupervisorJob guarantee that a failed operation
19+
* must not cancel the shared sequential scope for subsequent (sibling) operations.
20+
*/
21+
class DefaultRealtimeObjectAsyncTest {
22+
23+
private fun newObject(): DefaultRealtimeObject =
24+
DefaultRealtimeObject("ch", getMockAblyClientAdapter())
25+
26+
private fun boom() = AblyException.fromErrorInfo(ErrorInfo("boom", 400, 40000))
27+
28+
@After
29+
fun tearDown() = unmockkAll() // getMockAblyClientAdapter uses mockkStatic - clean up global state
30+
31+
@Test
32+
fun asyncFutureCompletesWithResult() {
33+
val result = newObject().asyncFuture { 42 }.get(2, TimeUnit.SECONDS)
34+
assertEquals(42, result)
35+
}
36+
37+
@Test
38+
fun asyncVoidFutureCompletesWithNullOnSuccess() {
39+
val result = newObject().asyncVoidFuture { /* no-op mutation */ }.get(2, TimeUnit.SECONDS)
40+
assertNull(result) // Void's only inhabitant
41+
}
42+
43+
@Test
44+
fun asyncFuturePropagatesException() {
45+
val boom = boom()
46+
val ex = assertFailsWith<ExecutionException> {
47+
newObject().asyncFuture<Unit> { throw boom }.get(2, TimeUnit.SECONDS)
48+
}
49+
assertSame(boom, ex.cause) // original exception surfaced to the caller
50+
}
51+
52+
@Test
53+
fun asyncVoidFuturePropagatesException() {
54+
val boom = boom()
55+
val ex = assertFailsWith<ExecutionException> {
56+
newObject().asyncVoidFuture { throw boom }.get(2, TimeUnit.SECONDS)
57+
}
58+
// the thenApply { null } bridge must forward the failure, not swallow it
59+
assertSame(boom, ex.cause)
60+
}
61+
62+
@Test
63+
fun failedOperationDoesNotCancelScopeForSiblings() {
64+
val obj = newObject()
65+
val boom = boom()
66+
// a mutating op fails on the sequential scope...
67+
assertFailsWith<ExecutionException> {
68+
obj.asyncVoidFuture { throw boom }.get(2, TimeUnit.SECONDS)
69+
}
70+
// ...and a subsequent op on the same scope still succeeds - proves SupervisorJob isolation
71+
// (a plain Job would have cancelled the scope, failing this call).
72+
assertEquals(7, obj.asyncFuture { 7 }.get(2, TimeUnit.SECONDS))
73+
}
74+
}

0 commit comments

Comments
 (0)