Skip to content

Commit 3da1f1d

Browse files
authored
Message and concurrent payload visitors (temporalio#2902)
1 parent 273f28a commit 3da1f1d

18 files changed

Lines changed: 2542 additions & 73 deletions

File tree

temporal-sdk/build.gradle

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,41 @@ dependencies {
6565
java21Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava }
6666
}
6767

68+
// --- Payload visitor code generation ---
69+
// A build-time generator (compiled in its own source set against the proto classes from
70+
// temporal-serviceclient) emits GeneratedPayloadVisitor.java, which knows how to walk every
71+
// payload-bearing Temporal API message. The generated source is added to the main source set.
72+
sourceSets {
73+
payloadVisitorGenerator {
74+
java {
75+
srcDirs = ['src/payloadVisitorGenerator/java']
76+
}
77+
}
78+
}
79+
80+
dependencies {
81+
payloadVisitorGeneratorImplementation project(':temporal-serviceclient')
82+
}
83+
84+
def generatedPayloadVisitorDir = layout.buildDirectory.dir('generated/payloadvisitor/java')
85+
86+
def generatePayloadVisitor = tasks.register('generatePayloadVisitor', JavaExec) {
87+
dependsOn 'compilePayloadVisitorGeneratorJava'
88+
classpath = sourceSets.payloadVisitorGenerator.runtimeClasspath
89+
mainClass = 'io.temporal.internal.payload.visitor.gen.PayloadVisitorGenerator'
90+
args generatedPayloadVisitorDir.get().asFile.absolutePath
91+
inputs.files(sourceSets.payloadVisitorGenerator.runtimeClasspath)
92+
outputs.dir(generatedPayloadVisitorDir)
93+
}
94+
95+
sourceSets.main.java.srcDir(generatePayloadVisitor)
96+
97+
tasks.named('compilePayloadVisitorGeneratorJava') {
98+
options.encoding = 'UTF-8'
99+
options.compilerArgs << '-Xlint:none' << '-Xlint:deprecation' << '-Werror'
100+
options.errorprone.error('MissingCasesInEnumSwitch')
101+
}
102+
68103
tasks.named('compileJava17Java') {
69104
options.release = 17
70105
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package io.temporal.internal.common;
2+
3+
import java.util.ArrayDeque;
4+
import java.util.Queue;
5+
import java.util.concurrent.CompletableFuture;
6+
import java.util.concurrent.locks.ReentrantLock;
7+
8+
/**
9+
* A simple async semaphore. Unfortunately there's not any readily available properly licensed
10+
* library I could find for this which is a bit shocking, but this implementation should be suitable
11+
* for our needs.
12+
*/
13+
public final class AsyncSemaphore {
14+
private final ReentrantLock lock = new ReentrantLock();
15+
private final Queue<CompletableFuture<Void>> waiters = new ArrayDeque<>();
16+
private int permits;
17+
18+
public AsyncSemaphore(int initialPermits) {
19+
this.permits = initialPermits;
20+
}
21+
22+
/**
23+
* Acquire a permit asynchronously. If a permit is available, returns a completed future,
24+
* otherwise returns a future that will be completed when a permit is released.
25+
*/
26+
public CompletableFuture<Void> acquire() {
27+
lock.lock();
28+
try {
29+
if (permits > 0) {
30+
permits--;
31+
return CompletableFuture.completedFuture(null);
32+
} else {
33+
CompletableFuture<Void> waiter = new CompletableFuture<>();
34+
waiters.add(waiter);
35+
return waiter;
36+
}
37+
} finally {
38+
lock.unlock();
39+
}
40+
}
41+
42+
public boolean tryAcquire() {
43+
lock.lock();
44+
try {
45+
if (permits > 0) {
46+
permits--;
47+
return true;
48+
}
49+
return false;
50+
} finally {
51+
lock.unlock();
52+
}
53+
}
54+
55+
/**
56+
* Release a permit. If there are waiting futures, completes the next one instead of incrementing
57+
* the permit count.
58+
*/
59+
public void release() {
60+
lock.lock();
61+
try {
62+
CompletableFuture<Void> waiter = waiters.poll();
63+
if (waiter != null) {
64+
if (!waiter.complete(null) && waiter.isCancelled()) {
65+
// If this waiter was cancelled, we need to release another permit, since this waiter
66+
// is now useless
67+
release();
68+
}
69+
} else {
70+
permits++;
71+
}
72+
} finally {
73+
lock.unlock();
74+
}
75+
}
76+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import com.google.protobuf.Message;
4+
5+
/**
6+
* Generated traversal for one message type: visits the message's payload fields and recurses into
7+
* its child messages. There is one per message type that can contain a payload.
8+
*/
9+
interface GeneratedVisitor {
10+
void visit(Traversal traversal, Message.Builder builder);
11+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import com.google.protobuf.Message;
4+
import java.util.function.Supplier;
5+
6+
/**
7+
* How to traverse one message type, and how to create an empty builder for it (used to unpack
8+
* {@code google.protobuf.Any} values).
9+
*/
10+
final class MessageRegistryEntry {
11+
final GeneratedVisitor visitor;
12+
final Supplier<Message.Builder> newBuilder;
13+
14+
MessageRegistryEntry(GeneratedVisitor visitor, Supplier<Message.Builder> newBuilder) {
15+
this.visitor = visitor;
16+
this.newBuilder = newBuilder;
17+
}
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import com.google.protobuf.MessageOrBuilder;
4+
5+
/**
6+
* Callback invoked when traversal enters a proto message. The returned value becomes the contextual
7+
* value in scope for that message and everything within it, and is restored to the enclosing value
8+
* once traversal leaves the message. The message is provided as a builder and may be inspected or
9+
* mutated.
10+
*
11+
* @param <C> type of the contextual value
12+
*/
13+
@FunctionalInterface
14+
interface MessageVisitor<C> {
15+
/**
16+
* Handles a message being entered and returns the contextual value for it and its contents.
17+
*
18+
* @param current the contextual value in scope from the enclosing message
19+
* @param message the message being entered
20+
* @return the contextual value to use for this message and its contents
21+
*/
22+
C onEnter(C current, MessageOrBuilder message);
23+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import java.util.Objects;
4+
import javax.annotation.Nonnull;
5+
import javax.annotation.Nullable;
6+
7+
/**
8+
* Options for visiting the messages of a proto message, without visiting individual payloads.
9+
*
10+
* @param <C> type of the contextual value supplied to the visitor
11+
*/
12+
final class MessageVisitorOptions<C> {
13+
private final @Nonnull MessageVisitor<C> messageVisitor;
14+
private final @Nullable C initialContext;
15+
16+
private MessageVisitorOptions(Builder<C> b) {
17+
this.messageVisitor = b.messageVisitor;
18+
this.initialContext = b.initialContext;
19+
}
20+
21+
public static <C> Builder<C> newBuilder(@Nonnull MessageVisitor<C> messageVisitor) {
22+
return new Builder<>(messageVisitor);
23+
}
24+
25+
@Nonnull
26+
public MessageVisitor<C> getMessageVisitor() {
27+
return messageVisitor;
28+
}
29+
30+
@Nullable
31+
public C getInitialContext() {
32+
return initialContext;
33+
}
34+
35+
public static final class Builder<C> {
36+
private final @Nonnull MessageVisitor<C> messageVisitor;
37+
private C initialContext;
38+
39+
private Builder(@Nonnull MessageVisitor<C> messageVisitor) {
40+
this.messageVisitor = Objects.requireNonNull(messageVisitor, "messageVisitor");
41+
}
42+
43+
/** The contextual value in scope before any message is entered. */
44+
public Builder<C> setInitialContext(@Nullable C initialContext) {
45+
this.initialContext = initialContext;
46+
return this;
47+
}
48+
49+
public MessageVisitorOptions<C> build() {
50+
return new MessageVisitorOptions<>(this);
51+
}
52+
}
53+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import com.google.protobuf.Message;
4+
import javax.annotation.Nonnull;
5+
6+
/**
7+
* Visits the messages within a proto message, invoking the message visitor on each, without
8+
* visiting individual payloads. Only messages that can contain a payload are visited.
9+
*
10+
* <p>This is an SDK-internal utility; it is not part of the public API.
11+
*/
12+
final class MessageVisitors {
13+
private MessageVisitors() {}
14+
15+
/** Visits the messages in {@code builder} in place. */
16+
public static <C> void visit(
17+
@Nonnull Message.Builder builder, @Nonnull MessageVisitorOptions<C> options) {
18+
Traversal traversal =
19+
new Traversal(
20+
null,
21+
options.getMessageVisitor(),
22+
options.getInitialContext(),
23+
/* skipSearchAttributes= */ false,
24+
/* skipHeaders= */ false,
25+
1,
26+
GeneratedPayloadVisitor.REGISTRY);
27+
traversal.dispatch(builder);
28+
// No payload visits, so execute() completes inline; join() returns at once. Message-visitor
29+
// errors throw from dispatch above.
30+
traversal.execute().join();
31+
}
32+
33+
/** Returns a copy with any changes applied; {@code message} is unchanged. */
34+
@SuppressWarnings("unchecked")
35+
public static <C, T extends Message> T visit(
36+
@Nonnull T message, @Nonnull MessageVisitorOptions<C> options) {
37+
Message.Builder builder = message.toBuilder();
38+
visit(builder, options);
39+
return (T) builder.build();
40+
}
41+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import io.temporal.api.common.v1.Payload;
4+
import java.util.List;
5+
import java.util.concurrent.CompletableFuture;
6+
7+
/**
8+
* Callback completing with the list that replaces {@code payloads}; complete with the same list to
9+
* leave them unchanged. Asynchronous so I/O-backed implementations (e.g. external storage) compose
10+
* without blocking a thread per call; a synchronous one returns {@link
11+
* CompletableFuture#completedFuture}.
12+
*
13+
* <p>For a single-payload field the visitor must complete with exactly one payload. With
14+
* concurrency greater than one, several visits may be in flight at once, so implementations must be
15+
* thread-safe.
16+
*
17+
* @param <C> type of the contextual value supplied to each visit
18+
*/
19+
@FunctionalInterface
20+
interface PayloadVisitor<C> {
21+
CompletableFuture<List<Payload>> visit(C context, List<Payload> payloads);
22+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package io.temporal.internal.payload.visitor;
2+
3+
import java.util.Objects;
4+
import javax.annotation.Nonnull;
5+
import javax.annotation.Nullable;
6+
7+
/**
8+
* Options for visiting the payloads of a proto message.
9+
*
10+
* @param <C> type of the contextual value supplied to the visitor
11+
*/
12+
final class PayloadVisitorOptions<C> {
13+
private final @Nonnull PayloadVisitor<C> payloadVisitor;
14+
private final @Nullable MessageVisitor<C> messageVisitor;
15+
private final @Nullable C initialContext;
16+
private final boolean skipSearchAttributes;
17+
private final boolean skipHeaders;
18+
private final int concurrency;
19+
20+
private PayloadVisitorOptions(Builder<C> b) {
21+
this.payloadVisitor = b.payloadVisitor;
22+
this.messageVisitor = b.messageVisitor;
23+
this.initialContext = b.initialContext;
24+
this.skipSearchAttributes = b.skipSearchAttributes;
25+
this.skipHeaders = b.skipHeaders;
26+
this.concurrency = b.concurrency;
27+
}
28+
29+
public static <C> Builder<C> newBuilder(@Nonnull PayloadVisitor<C> payloadVisitor) {
30+
return new Builder<>(payloadVisitor);
31+
}
32+
33+
@Nonnull
34+
public PayloadVisitor<C> getPayloadVisitor() {
35+
return payloadVisitor;
36+
}
37+
38+
@Nullable
39+
public MessageVisitor<C> getMessageVisitor() {
40+
return messageVisitor;
41+
}
42+
43+
@Nullable
44+
public C getInitialContext() {
45+
return initialContext;
46+
}
47+
48+
public boolean isSkipSearchAttributes() {
49+
return skipSearchAttributes;
50+
}
51+
52+
public boolean isSkipHeaders() {
53+
return skipHeaders;
54+
}
55+
56+
public int getConcurrency() {
57+
return concurrency;
58+
}
59+
60+
public static final class Builder<C> {
61+
private final @Nonnull PayloadVisitor<C> payloadVisitor;
62+
private MessageVisitor<C> messageVisitor;
63+
private C initialContext;
64+
private boolean skipSearchAttributes;
65+
private boolean skipHeaders;
66+
private int concurrency = 1;
67+
68+
private Builder(@Nonnull PayloadVisitor<C> payloadVisitor) {
69+
this.payloadVisitor = Objects.requireNonNull(payloadVisitor, "payloadVisitor");
70+
}
71+
72+
public Builder<C> setMessageVisitor(@Nullable MessageVisitor<C> messageVisitor) {
73+
this.messageVisitor = messageVisitor;
74+
return this;
75+
}
76+
77+
/** The contextual value in scope before any message is entered. */
78+
public Builder<C> setInitialContext(@Nullable C initialContext) {
79+
this.initialContext = initialContext;
80+
return this;
81+
}
82+
83+
public Builder<C> setSkipSearchAttributes(boolean skipSearchAttributes) {
84+
this.skipSearchAttributes = skipSearchAttributes;
85+
return this;
86+
}
87+
88+
public Builder<C> setSkipHeaders(boolean skipHeaders) {
89+
this.skipHeaders = skipHeaders;
90+
return this;
91+
}
92+
93+
/** At least {@code 1} (sequential). Bounds outstanding visit futures; no executor needed. */
94+
public Builder<C> setConcurrency(int concurrency) {
95+
this.concurrency = concurrency;
96+
return this;
97+
}
98+
99+
public PayloadVisitorOptions<C> build() {
100+
if (concurrency < 1) {
101+
throw new IllegalArgumentException("concurrency must be at least 1, got " + concurrency);
102+
}
103+
return new PayloadVisitorOptions<>(this);
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)