Skip to content

Commit 13f9a68

Browse files
Introduce unsnapshottableIdentifier() to allow transient WorkflowIdentifiers.
This fixes #79 and unblocks the ability to implement GUWT Workers as Workflows. `WorkflowIdentifier` can now indicate that it is not serializable by returning null from `toByteString()`, in which case neither it nor the snapshot of the workflow it identifies will be serialized. Because these identifiers do not need to be serialized, they can contain arbitrary values such as `KType`s, which is how Workers distinguish themselves for example. Workers also do not need to be snapshotted, so this works out.
1 parent 77c9d21 commit 13f9a68

File tree

6 files changed

+247
-82
lines changed

6 files changed

+247
-82
lines changed

workflow-core/api/workflow-core.api

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,12 @@ public final class com/squareup/workflow/WorkflowIdentifier {
172172
public static final field Companion Lcom/squareup/workflow/WorkflowIdentifier$Companion;
173173
public fun equals (Ljava/lang/Object;)Z
174174
public fun hashCode ()I
175+
public final fun maybeToByteString ()Lokio/ByteString;
175176
public fun toString ()Ljava/lang/String;
176-
public final fun write (Lokio/BufferedSink;)V
177177
}
178178

179179
public final class com/squareup/workflow/WorkflowIdentifier$Companion {
180-
public final fun read (Lokio/BufferedSource;)Lcom/squareup/workflow/WorkflowIdentifier;
180+
public final fun parse (Lokio/ByteString;)Lcom/squareup/workflow/WorkflowIdentifier;
181181
}
182182

183183
public final class com/squareup/workflow/WorkflowOutput {
@@ -225,6 +225,7 @@ public final class com/squareup/workflow/Workflows {
225225
public static synthetic fun stateful$default (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/squareup/workflow/StatefulWorkflow;
226226
public static final fun stateless (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/Workflow;
227227
public static final fun transform (Lcom/squareup/workflow/Worker;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/Worker;
228+
public static final fun unsnapshottableIdentifier (Lkotlin/reflect/KType;)Lcom/squareup/workflow/WorkflowIdentifier;
228229
public static final fun workflowAction (Lcom/squareup/workflow/StatefulWorkflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/WorkflowAction;
229230
public static final fun workflowAction (Lcom/squareup/workflow/StatefulWorkflow;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/WorkflowAction;
230231
public static synthetic fun workflowAction$default (Lcom/squareup/workflow/StatefulWorkflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow/WorkflowAction;

workflow-core/src/main/java/com/squareup/workflow/WorkflowIdentifier.kt

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818

1919
package com.squareup.workflow
2020

21-
import okio.BufferedSink
22-
import okio.BufferedSource
21+
import okio.Buffer
22+
import okio.ByteString
2323
import okio.EOFException
2424
import kotlin.LazyThreadSafetyMode.PUBLICATION
25+
import kotlin.reflect.KAnnotatedElement
2526
import kotlin.reflect.KClass
27+
import kotlin.reflect.KType
2628

2729
/**
2830
* Represents a [Workflow]'s "identity" and is used by the runtime to determine whether a workflow
@@ -31,61 +33,82 @@ import kotlin.reflect.KClass
3133
*
3234
* A workflow's identity consists primarily of its concrete type (i.e. the class that implements
3335
* the [Workflow] interface). Two workflows of the same concrete type are considered identical.
34-
*
3536
* However, if a workflow class implements [ImpostorWorkflow], the identifier will also include
3637
* that workflow's [ImpostorWorkflow.realIdentifier].
3738
*
39+
* Instances of this class are [equatable][equals] and [hashable][hashCode].
40+
*
41+
* ## Identifiers and snapshots
42+
*
43+
* Since workflows can be [serialized][StatefulWorkflow.snapshotState], workflows' identifiers must
44+
* also be serializable in order to match workflows back up with their snapshots when restoring.
45+
* However, some [WorkflowIdentifier]s may represent workflows that cannot be snapshotted. When an
46+
* identifier is not snapshottable, [maybeToByteString] will return null, and any identifiers that
47+
* reference [ImpostorWorkflow]s whose [ImpostorWorkflow.realIdentifier] is not snapshottable will
48+
* also not be snapshottable. Such identifiers are created with [unsnapshottableIdentifier], but
49+
* should not be used to wrap arbitrary workflows since those workflows may expect to be
50+
* snapshotted.
51+
*
3852
* @constructor
39-
* @param type The [KClass] of the [Workflow] this identifier identifies.
53+
* @param type The [KClass] of the [Workflow] this identifier identifies, or the [KType] of an
54+
* [unsnapshottableIdentifier].
4055
* @param proxiedIdentifier An optional identifier from [ImpostorWorkflow.realIdentifier] that will
4156
* be used to further narrow the scope of this identifier.
4257
*/
4358
@ExperimentalWorkflowApi
4459
class WorkflowIdentifier internal constructor(
45-
private val type: KClass<out Workflow<*, *, *>>,
46-
private val proxiedIdentifier: WorkflowIdentifier?
60+
private val type: KAnnotatedElement,
61+
private val proxiedIdentifier: WorkflowIdentifier? = null
4762
) {
4863

4964
/**
50-
* The fully-qualified name of [type]. Computed lazily.
65+
* The fully-qualified name of the type of workflow this identifier identifies. Computed lazily
66+
* and cached.
5167
*/
52-
private val typeString: String by lazy(PUBLICATION) { type.java.name }
68+
private val typeName: String by lazy(PUBLICATION) {
69+
if (type is KClass<*>) type.java.name else type.toString()
70+
}
5371

5472
/**
55-
* Returns a description of this identifier including the name of its workflow type and any
56-
* [proxiedIdentifier]s. Computes [typeString] if it has not already been computed.
73+
* If this identifier is snapshottable, returns the serialized form of the identifier.
74+
* If it is not snapshottable, returns null.
5775
*/
58-
override fun toString(): String =
59-
generateSequence(this) { it.proxiedIdentifier }
60-
.joinToString { it.typeString }
61-
.let { "WorkflowIdentifier($it)" }
76+
fun maybeToByteString(): ByteString? {
77+
if (type !is KClass<*>) return null
6278

63-
/**
64-
* Serializes this identifier to the sink. It can be read back with [WorkflowIdentifier.read].
65-
*/
66-
fun write(sink: BufferedSink) {
67-
sink.writeUtf8WithLength(typeString)
68-
if (proxiedIdentifier != null) {
69-
sink.writeByte(PROXY_IDENTIFIER_TAG.toInt())
70-
proxiedIdentifier.write(sink)
71-
} else {
72-
sink.writeByte(NO_PROXY_IDENTIFIER_TAG.toInt())
79+
val proxiedBytes = proxiedIdentifier?.let {
80+
// If we have a proxied identifier but it's not serializable, then we can't be serializable
81+
// either.
82+
it.maybeToByteString() ?: return null
83+
}
84+
85+
return Buffer().let { sink ->
86+
sink.writeUtf8WithLength(typeName)
87+
if (proxiedBytes != null) {
88+
sink.writeByte(PROXY_IDENTIFIER_TAG.toInt())
89+
sink.write(proxiedBytes)
90+
} else {
91+
sink.writeByte(NO_PROXY_IDENTIFIER_TAG.toInt())
92+
}
93+
sink.readByteString()
7394
}
7495
}
7596

7697
/**
77-
* Determines equality to another [WorkflowIdentifier] by comparing their [type]s and their
78-
* [proxiedIdentifier]s.
98+
* Returns a description of this identifier including the name of its workflow type and any
99+
* [ImpostorWorkflow.realIdentifier]s.
79100
*/
101+
override fun toString(): String =
102+
generateSequence(this) { it.proxiedIdentifier }
103+
.joinToString { it.typeName }
104+
.let { "WorkflowIdentifier($it)" }
105+
80106
override fun equals(other: Any?): Boolean = when {
81107
this === other -> true
82108
other !is WorkflowIdentifier -> false
83109
else -> type == other.type && proxiedIdentifier == other.proxiedIdentifier
84110
}
85111

86-
/**
87-
* Derives a hashcode from [type] and [proxiedIdentifier].
88-
*/
89112
override fun hashCode(): Int {
90113
var result = type.hashCode()
91114
result = 31 * result + (proxiedIdentifier?.hashCode() ?: 0)
@@ -97,18 +120,20 @@ class WorkflowIdentifier internal constructor(
97120
private const val PROXY_IDENTIFIER_TAG = 1.toByte()
98121

99122
/**
100-
* Reads a [WorkflowIdentifier] from [source].
123+
* Reads a [WorkflowIdentifier] from a [ByteString] as written by [maybeToByteString].
101124
*
102125
* @throws IllegalArgumentException if the source does not contain a valid [WorkflowIdentifier]
103126
* @throws ClassNotFoundException if one of the workflow types can't be found in the class
104127
* loader
105128
*/
106-
fun read(source: BufferedSource): WorkflowIdentifier? {
129+
fun parse(bytes: ByteString): WorkflowIdentifier? = Buffer().let { source ->
130+
source.write(bytes)
131+
107132
try {
108133
val typeString = source.readUtf8WithLength()
109134
val proxiedIdentifier = when (source.readByte()) {
110135
NO_PROXY_IDENTIFIER_TAG -> null
111-
PROXY_IDENTIFIER_TAG -> read(source)
136+
PROXY_IDENTIFIER_TAG -> parse(source.readByteString())
112137
else -> throw IllegalArgumentException("Invalid WorkflowIdentifier")
113138
}
114139

@@ -122,6 +147,20 @@ class WorkflowIdentifier internal constructor(
122147
}
123148
}
124149

150+
/**
151+
* Creates a [WorkflowIdentifier] that is not capable of being snapshotted and will cause any
152+
* [ImpostorWorkflow] workflow identified by it to also not be snapshotted.
153+
*
154+
* **This function should not be used for [ImpostorWorkflow]s that wrap arbitrary workflows**, since
155+
* those workflows may expect to be on snapshotted. Using such identifiers _anywhere in the
156+
* [ImpostorWorkflow.realIdentifier] chain_ will disable snapshotting for that workflow. **This
157+
* function should only be used for [ImpostorWorkflow]s that wrap a closed set of known workflow
158+
* types.**
159+
*/
160+
@ExperimentalWorkflowApi
161+
@Suppress("unused")
162+
fun unsnapshottableIdentifier(type: KType): WorkflowIdentifier = WorkflowIdentifier(type)
163+
125164
/**
126165
* The [WorkflowIdentifier] that identifies this [Workflow].
127166
*/

workflow-core/src/test/java/com/squareup/workflow/WorkflowIdentifierTest.kt

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616
package com.squareup.workflow
1717

1818
import okio.Buffer
19+
import okio.ByteString
20+
import kotlin.reflect.KType
21+
import kotlin.reflect.typeOf
1922
import kotlin.test.Test
2023
import kotlin.test.assertEquals
2124
import kotlin.test.assertFailsWith
2225
import kotlin.test.assertNotEquals
26+
import kotlin.test.assertNull
2327

24-
@OptIn(ExperimentalWorkflowApi::class)
28+
@OptIn(ExperimentalWorkflowApi::class, ExperimentalStdlibApi::class)
2529
class WorkflowIdentifierTest {
2630

2731
@Test fun `flat identifier toString`() {
@@ -43,8 +47,8 @@ class WorkflowIdentifierTest {
4347

4448
@Test fun `restored identifier toString`() {
4549
val id = TestWorkflow1.identifier
46-
val serializedId = Buffer().also(id::write)
47-
val restoredId = WorkflowIdentifier.read(serializedId)
50+
val serializedId = id.maybeToByteString()!!
51+
val restoredId = WorkflowIdentifier.parse(serializedId)
4852
assertEquals(id.toString(), restoredId.toString())
4953
}
5054

@@ -82,24 +86,24 @@ class WorkflowIdentifierTest {
8286

8387
@Test fun `identifier restored from source is equal to itself`() {
8488
val id = TestWorkflow1.identifier
85-
val serializedId = Buffer().also(id::write)
86-
val restoredId = WorkflowIdentifier.read(serializedId)
89+
val serializedId = id.maybeToByteString()!!
90+
val restoredId = WorkflowIdentifier.parse(serializedId)
8791
assertEquals(id, restoredId)
8892
assertEquals(id.hashCode(), restoredId.hashCode())
8993
}
9094

9195
@Test fun `identifier restored from source is not equal to different identifier`() {
9296
val id1 = TestWorkflow1.identifier
9397
val id2 = TestWorkflow2.identifier
94-
val serializedId = Buffer().also(id1::write)
95-
val restoredId = WorkflowIdentifier.read(serializedId)
98+
val serializedId = id1.maybeToByteString()!!
99+
val restoredId = WorkflowIdentifier.parse(serializedId)
96100
assertNotEquals(id2, restoredId)
97101
}
98102

99103
@Test fun `impostor identifier restored from source is equal to itself`() {
100104
val id = TestImpostor1(TestWorkflow1).identifier
101-
val serializedId = Buffer().also(id::write)
102-
val restoredId = WorkflowIdentifier.read(serializedId)
105+
val serializedId = id.maybeToByteString()!!
106+
val restoredId = WorkflowIdentifier.parse(serializedId)
103107
assertEquals(id, restoredId)
104108
assertEquals(id.hashCode(), restoredId.hashCode())
105109
}
@@ -108,48 +112,92 @@ class WorkflowIdentifierTest {
108112
fun `impostor identifier restored from source is not equal to impostor with different proxied class`() {
109113
val id1 = TestImpostor1(TestWorkflow1).identifier
110114
val id2 = TestImpostor1(TestWorkflow2).identifier
111-
val serializedId = Buffer().also(id1::write)
112-
val restoredId = WorkflowIdentifier.read(serializedId)
115+
val serializedId = id1.maybeToByteString()!!
116+
val restoredId = WorkflowIdentifier.parse(serializedId)
113117
assertNotEquals(id2, restoredId)
114118
}
115119

116120
@Test
117121
fun `impostor identifier restored from source is not equal to different impostor with same proxied class`() {
118122
val id1 = TestImpostor1(TestWorkflow1).identifier
119123
val id2 = TestImpostor2(TestWorkflow1).identifier
120-
val serializedId = Buffer().also(id1::write)
121-
val restoredId = WorkflowIdentifier.read(serializedId)
124+
val serializedId = id1.maybeToByteString()!!
125+
val restoredId = WorkflowIdentifier.parse(serializedId)
122126
assertNotEquals(id2, restoredId)
123127
}
124128

125129
@Test fun `read from empty source throws`() {
126-
val source = Buffer()
127130
assertFailsWith<IllegalArgumentException> {
128-
WorkflowIdentifier.read(source)
131+
WorkflowIdentifier.parse(ByteString.EMPTY)
129132
}
130133
}
131134

132135
@Test fun `read from invalid source throws`() {
133136
val source = Buffer().apply { writeUtf8("invalid data") }
137+
.readByteString()
134138
assertFailsWith<IllegalArgumentException> {
135-
WorkflowIdentifier.read(source)
139+
WorkflowIdentifier.parse(source)
136140
}
137141
}
138142

139143
@Test fun `read from corrupted source throws`() {
140-
val source = Buffer().also(TestWorkflow1.identifier::write)
141-
.readByteArray()
144+
val source = TestWorkflow1.identifier.maybeToByteString()!!
145+
.toByteArray()
142146
source.indices.reversed()
143147
.take(10)
144148
.forEach { i ->
145149
source[i] = 0
146150
}
147151
val corruptedSource = Buffer().apply { write(source) }
152+
.readByteString()
148153
assertFailsWith<ClassNotFoundException> {
149-
WorkflowIdentifier.read(corruptedSource)
154+
WorkflowIdentifier.parse(corruptedSource)
150155
}
151156
}
152157

158+
@Test fun `unsnapshottable identifier returns null ByteString`() {
159+
val id = unsnapshottableIdentifier(typeOf<TestWorkflow1>())
160+
assertNull(id.maybeToByteString())
161+
}
162+
163+
@Test fun `unsnapshottable identifier toString()`() {
164+
val id = unsnapshottableIdentifier(typeOf<String>())
165+
assertEquals(
166+
"WorkflowIdentifier(${String::class.java.name} (Kotlin reflection is not available))",
167+
id.toString()
168+
)
169+
}
170+
171+
@Test fun `unsnapshottable identifiers for same class are equal`() {
172+
val id1 = unsnapshottableIdentifier(typeOf<String>())
173+
val id2 = unsnapshottableIdentifier(typeOf<String>())
174+
assertEquals(id1, id2)
175+
}
176+
177+
@Test fun `unsnapshottable identifiers for different class are not equal`() {
178+
val id1 = unsnapshottableIdentifier(typeOf<String>())
179+
val id2 = unsnapshottableIdentifier(typeOf<Int>())
180+
assertNotEquals(id1, id2)
181+
}
182+
183+
@Test fun `unsnapshottable impostor identifier returns null ByteString`() {
184+
val id = TestUnsnapshottableImpostor(typeOf<String>()).identifier
185+
assertNull(id.maybeToByteString())
186+
}
187+
188+
@Test fun `impostor of unsnapshottable impostor identifier returns null ByteString`() {
189+
val id = TestImpostor1(TestUnsnapshottableImpostor(typeOf<String>())).identifier
190+
assertNull(id.maybeToByteString())
191+
}
192+
193+
@Test fun `unsnapshottable impostor identifier toString()`() {
194+
val id = TestUnsnapshottableImpostor(typeOf<String>()).identifier
195+
assertEquals(
196+
"WorkflowIdentifier(${TestUnsnapshottableImpostor::class.java.name}, " +
197+
"${String::class.java.name} (Kotlin reflection is not available))", id.toString()
198+
)
199+
}
200+
153201
private object TestWorkflow1 : Workflow<Nothing, Nothing, Nothing> {
154202
override fun asStatefulWorkflow(): StatefulWorkflow<Nothing, *, Nothing, Nothing> =
155203
throw NotImplementedError()
@@ -175,4 +223,12 @@ class WorkflowIdentifierTest {
175223
override fun asStatefulWorkflow(): StatefulWorkflow<Nothing, *, Nothing, Nothing> =
176224
throw NotImplementedError()
177225
}
226+
227+
private class TestUnsnapshottableImpostor(
228+
type: KType
229+
) : Workflow<Nothing, Nothing, Nothing>, ImpostorWorkflow {
230+
override val realIdentifier: WorkflowIdentifier = unsnapshottableIdentifier(type)
231+
override fun asStatefulWorkflow(): StatefulWorkflow<Nothing, *, Nothing, Nothing> =
232+
throw NotImplementedError()
233+
}
178234
}

0 commit comments

Comments
 (0)