Skip to content

Commit e931852

Browse files
Merge pull request #84 from square/zachklipp/transient-workflowidentifiers
Introduce unsnapshottableIdentifier() to allow transient WorkflowIdentifiers.
2 parents 12bfb12 + 1a1eced commit e931852

File tree

6 files changed

+247
-82
lines changed

6 files changed

+247
-82
lines changed

workflow-core/api/workflow-core.api

+3-2
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,12 @@ public final class com/squareup/workflow/WorkflowIdentifier {
173173
public static final field Companion Lcom/squareup/workflow/WorkflowIdentifier$Companion;
174174
public fun equals (Ljava/lang/Object;)Z
175175
public fun hashCode ()I
176+
public final fun toByteStringOrNull ()Lokio/ByteString;
176177
public fun toString ()Ljava/lang/String;
177-
public final fun write (Lokio/BufferedSink;)V
178178
}
179179

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

184184
public final class com/squareup/workflow/WorkflowOutput {
@@ -226,6 +226,7 @@ public final class com/squareup/workflow/Workflows {
226226
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;
227227
public static final fun stateless (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow/Workflow;
228228
public static final fun transform (Lcom/squareup/workflow/Worker;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/Worker;
229+
public static final fun unsnapshottableIdentifier (Lkotlin/reflect/KType;)Lcom/squareup/workflow/WorkflowIdentifier;
229230
public static final fun workflowAction (Lcom/squareup/workflow/StatefulWorkflow;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/WorkflowAction;
230231
public static final fun workflowAction (Lcom/squareup/workflow/StatefulWorkflow;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow/WorkflowAction;
231232
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

+71-32
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, [toByteStringOrNull] 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 toByteStringOrNull(): 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.toByteStringOrNull() ?: 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 [toByteStringOrNull].
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

+75-19
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.toByteStringOrNull()!!
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.toByteStringOrNull()!!
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.toByteStringOrNull()!!
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.toByteStringOrNull()!!
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.toByteStringOrNull()!!
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.toByteStringOrNull()!!
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.toByteStringOrNull()!!
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.toByteStringOrNull())
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.toByteStringOrNull())
186+
}
187+
188+
@Test fun `impostor of unsnapshottable impostor identifier returns null ByteString`() {
189+
val id = TestImpostor1(TestUnsnapshottableImpostor(typeOf<String>())).identifier
190+
assertNull(id.toByteStringOrNull())
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)