Skip to content

Commit f0c44d7

Browse files
committed
update replay-safe logging for .NET parity
1 parent 0ed40fa commit f0c44d7

7 files changed

Lines changed: 213 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## Unreleased
2-
* Add replay-safe logging: `createReplaySafeLogger(String)`, `createReplaySafeLogger(Class<?>)`, `getReplaySafeLoggerFactory()`, and `getLoggerFactory()` on `TaskOrchestrationContext`. Wraps SLF4J loggers to suppress log output during orchestration replay. Requires `slf4j-api >= 2.0.0` (new transitive dependency).
2+
* Add replay-safe logging on `TaskOrchestrationContext`: `createReplaySafeLogger(String)`, `createReplaySafeLogger(Class<?>)`, `getReplaySafeLoggerFactory()`, and `getLoggerFactory()`. Wraps SLF4J loggers so both the classic API (`info`, `debug`, ...) and the SLF4J 2.x fluent API (`atInfo()`, `atDebug()`, ...) are no-ops during orchestration replay. Mirrors the .NET `ReplaySafeLogger` / `ReplaySafeLoggerFactory` surface.
3+
* **Compatibility note:** `slf4j-api` is now an `api`-scoped dependency (floor `2.0.0`). Downstream consumers still on `slf4j-api 1.7.x` via other transitive paths will be upgraded to `2.0.x` via Gradle/Maven version conflict resolution. SLF4J 2.x is backward-compatible at the API level for log call sites; SLF4J 1.x bindings (e.g. `slf4j-log4j12`, `logback-classic` <1.3) must be replaced with their 2.x equivalents.
34

45
## v1.9.0
56
* Fix entity locking deserialization and add Jackson support for EntityInstanceId/EntityMetadata ([#281](https://github.com/microsoft/durabletask-java/pull/281))

client/build.gradle

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ dependencies {
5252
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}"
5353
implementation "io.opentelemetry:opentelemetry-api:${openTelemetryVersion}"
5454
implementation "io.opentelemetry:opentelemetry-context:${openTelemetryVersion}"
55-
api "org.slf4j:slf4j-api:2.0.16"
55+
// slf4j-api is `api`-scoped because org.slf4j.Logger and org.slf4j.ILoggerFactory appear
56+
// on the public TaskOrchestrationContext surface. The version is the declared floor (2.0.0,
57+
// required for the SLF4J 2.x fluent API and NOPLoggingEventBuilder); downstream consumers
58+
// are free to resolve a newer 2.0.x patch.
59+
api "org.slf4j:slf4j-api:2.0.0"
5660

5761
testImplementation "io.opentelemetry:opentelemetry-sdk:${openTelemetryVersion}"
5862
testImplementation "io.opentelemetry:opentelemetry-sdk-trace:${openTelemetryVersion}"

client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggers.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ static ILoggerFactory unwrap(TaskOrchestrationContext context) {
3535
ILoggerFactory factory = context.getLoggerFactory();
3636
if (factory == null) {
3737
throw new IllegalStateException(
38-
"getLoggerFactory() returned null. Ensure the context's getLoggerFactory() " +
39-
"implementation returns a non-null ILoggerFactory.");
38+
"getLoggerFactory() returned null on context of type " +
39+
context.getClass().getName() +
40+
". Override TaskOrchestrationContext.getLoggerFactory() to return a non-null " +
41+
"ILoggerFactory (typically inner.getReplaySafeLoggerFactory() for wrapper contexts).");
4042
}
4143
int depth = 0;
4244
while (factory instanceof ReplaySafeLoggerFactory) {

client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -863,18 +863,35 @@ default Task<DurableHttpResponse> callHttp(String method, URI uri,
863863
/**
864864
* Returns the {@link ILoggerFactory} used by this context to resolve loggers.
865865
*
866-
* <p>By default this returns the SLF4J global {@link LoggerFactory#getILoggerFactory()}.
867-
* Wrapper contexts can override this method to substitute a custom factory &mdash;
868-
* for example, a wrapper context that wants its own logging to also be replay-safe should
869-
* return the inner context's {@link #getReplaySafeLoggerFactory()}.
866+
* <p><b>This method is an extension point for wrapper-context implementations, not a
867+
* convenience getter for orchestrator code.</b> Orchestrator code should always use
868+
* {@link #createReplaySafeLogger(String)} / {@link #createReplaySafeLogger(Class)} or
869+
* {@link #getReplaySafeLoggerFactory()} &mdash; never call {@code getLoggerFactory()}
870+
* directly to obtain a logger, as the returned factory produces <em>non-replay-safe</em>
871+
* loggers that will emit duplicate messages on every replay.
872+
*
873+
* <p>This is the Java analog of the .NET SDK's {@code protected abstract ILoggerFactory
874+
* LoggerFactory} property. Java interfaces cannot express {@code protected}, so this method
875+
* is exposed as a {@code public default} returning the SLF4J global
876+
* {@link LoggerFactory#getILoggerFactory()}. Wrapper contexts <b>should</b> override it
877+
* &mdash; for example, a wrapper that wants its own logging to also be replay-safe must
878+
* return the inner context's {@link #getReplaySafeLoggerFactory()}:
879+
* <pre>{@code
880+
* @Override
881+
* public ILoggerFactory getLoggerFactory() {
882+
* return inner.getReplaySafeLoggerFactory();
883+
* }
884+
* }</pre>
870885
*
871-
* <p>This method is the extension point that allows {@code createReplaySafeLogger(...)} and
872-
* {@link #getReplaySafeLoggerFactory()} to delegate logger creation. To prevent
873-
* double-wrapping, the SDK unwraps any nested factory returned from this method that is
874-
* itself a replay-safe wrapper.
886+
* <p>To prevent double-wrapping, the SDK unwraps any nested factory returned from this
887+
* method that is itself a replay-safe wrapper.
875888
*
876-
* @return the {@code ILoggerFactory} backing logger creation for this context
889+
* @return the {@code ILoggerFactory} backing logger creation for this context; <b>must not
890+
* be {@code null}</b>. Returning {@code null} from an override causes
891+
* {@link #createReplaySafeLogger(String)} and {@link #getReplaySafeLoggerFactory()}
892+
* to throw {@link IllegalStateException}.
877893
*/
894+
@Nonnull
878895
default ILoggerFactory getLoggerFactory() {
879896
return LoggerFactory.getILoggerFactory();
880897
}

client/src/test/java/com/microsoft/durabletask/ReplaySafeLoggerFactoryTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,30 @@ public ILoggerFactory getLoggerFactory() {
149149
assertTrue(ex.getMessage().contains("Maximum unwrap depth exceeded"));
150150
}
151151

152+
// -----------------------------------------------------------------------
153+
// Null factory from override — error message must name the offending context class
154+
// -----------------------------------------------------------------------
155+
156+
@Test
157+
void nullLoggerFactory_throwsIllegalStateExceptionNamingContextClass() {
158+
// A wrapper context whose getLoggerFactory() returns null (a contract violation)
159+
// should produce an error message that identifies the offending class so the
160+
// user knows where to look.
161+
TaskOrchestrationContext nullFactoryContext = new MinimalContext(false, null) {
162+
@Override
163+
public ILoggerFactory getLoggerFactory() {
164+
return null;
165+
}
166+
};
167+
168+
IllegalStateException ex = assertThrows(IllegalStateException.class,
169+
() -> nullFactoryContext.createReplaySafeLogger("test"));
170+
assertTrue(ex.getMessage().contains("returned null"),
171+
"Message should explain the null return: " + ex.getMessage());
172+
assertTrue(ex.getMessage().contains(nullFactoryContext.getClass().getName()),
173+
"Message should name the offending context class: " + ex.getMessage());
174+
}
175+
152176
// -----------------------------------------------------------------------
153177
// F-02: Verify wrapper-context forwards when inner is not replaying
154178
// (complementary to the suppression test above — proves single gate)

samples/build.gradle

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ task runTypedEntityProxySample(type: JavaExec) {
8383
environment dtsEnv
8484
}
8585

86+
task runReplaySafeLoggingPattern(type: JavaExec) {
87+
classpath = sourceSets.main.runtimeClasspath
88+
mainClass = 'io.durabletask.samples.ReplaySafeLoggingPattern'
89+
}
90+
8691
task printClasspath {
8792
doLast {
8893
println sourceSets.main.runtimeClasspath.asPath
@@ -114,4 +119,4 @@ dependencies {
114119

115120
// SLF4J binding for replay-safe logger sample output
116121
runtimeOnly 'org.slf4j:slf4j-simple:2.0.16'
117-
}
122+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
package io.durabletask.samples;
4+
5+
import com.microsoft.durabletask.DurableTaskClient;
6+
import com.microsoft.durabletask.DurableTaskGrpcClientBuilder;
7+
import com.microsoft.durabletask.DurableTaskGrpcWorker;
8+
import com.microsoft.durabletask.DurableTaskGrpcWorkerBuilder;
9+
import com.microsoft.durabletask.NewOrchestrationInstanceOptions;
10+
import com.microsoft.durabletask.OrchestrationMetadata;
11+
import com.microsoft.durabletask.TaskActivity;
12+
import com.microsoft.durabletask.TaskActivityFactory;
13+
import com.microsoft.durabletask.TaskOrchestration;
14+
import com.microsoft.durabletask.TaskOrchestrationFactory;
15+
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
19+
import java.io.IOException;
20+
import java.time.Duration;
21+
import java.util.concurrent.TimeoutException;
22+
23+
/**
24+
* Demonstrates the replay-safe logging API on {@code TaskOrchestrationContext}, the Java
25+
* counterpart of the .NET SDK's {@code ReplaySafeLoggerFactorySample}.
26+
*
27+
* <p>An orchestrator replays its history every time it resumes. A normal SLF4J logger therefore
28+
* emits the same message multiple times &mdash; once per replay. A logger obtained from
29+
* {@link com.microsoft.durabletask.TaskOrchestrationContext#createReplaySafeLogger(Class)}
30+
* suppresses output during replay and logs only on the first (non-replay) execution.
31+
*
32+
* <p>This sample runs an orchestrator that calls two activities. Each activity call causes the
33+
* orchestrator to yield and replay on resume, so the messages logged via the standard logger
34+
* appear more than once while the replay-safe logger's messages appear exactly once.
35+
*
36+
* <p>Run it like any other sample (requires a Durable Task sidecar / DTS emulator on the default
37+
* gRPC endpoint):
38+
* <pre>{@code
39+
* ./gradlew :samples:runReplaySafeLoggingPattern
40+
* }</pre>
41+
*
42+
* <h3>Wrapper-context pattern (.NET parity)</h3>
43+
* If you wrap {@code TaskOrchestrationContext} in your own type and want logging emitted from
44+
* the wrapper to also be replay-safe, override {@code getLoggerFactory()} to return the inner
45+
* context's {@code getReplaySafeLoggerFactory()}:
46+
* <pre>{@code
47+
* final class LoggingContext implements TaskOrchestrationContext {
48+
* private final TaskOrchestrationContext inner;
49+
* LoggingContext(TaskOrchestrationContext inner) { this.inner = inner; }
50+
*
51+
* @Override
52+
* public ILoggerFactory getLoggerFactory() {
53+
* return inner.getReplaySafeLoggerFactory(); // SDK unwraps to avoid double-wrapping
54+
* }
55+
* // ... forward all other methods to `inner` ...
56+
* }
57+
* }</pre>
58+
* Implementing the full wrapper requires forwarding every abstract method on the interface, so
59+
* it is omitted here for brevity. The mechanism is the same as the .NET SDK's
60+
* {@code protected override ILoggerFactory LoggerFactory => inner.ReplaySafeLoggerFactory;}.
61+
*/
62+
final class ReplaySafeLoggingPattern {
63+
64+
private static final String ORCHESTRATION_NAME = "ReplaySafeLoggingOrchestration";
65+
private static final String ECHO_ACTIVITY = "Echo";
66+
67+
public static void main(String[] args) throws IOException, InterruptedException, TimeoutException {
68+
DurableTaskGrpcWorker worker = createWorker();
69+
worker.start();
70+
71+
try {
72+
DurableTaskClient client = new DurableTaskGrpcClientBuilder().build();
73+
74+
String instanceId = client.scheduleNewOrchestrationInstance(
75+
ORCHESTRATION_NAME,
76+
new NewOrchestrationInstanceOptions().setInput("Seattle"));
77+
System.out.printf("Started new orchestration instance: %s%n", instanceId);
78+
79+
OrchestrationMetadata completed = client.waitForInstanceCompletion(
80+
instanceId,
81+
Duration.ofSeconds(30),
82+
true);
83+
84+
System.out.printf("Orchestration completed: %s%n", completed.getRuntimeStatus());
85+
System.out.printf("Output: %s%n", completed.readOutputAs(String.class));
86+
System.out.println();
87+
System.out.println(
88+
"Note: the non-replay-safe logger's messages should appear multiple times " +
89+
"(once per replay), while the replay-safe logger's messages appear once.");
90+
} finally {
91+
worker.stop();
92+
}
93+
}
94+
95+
private static DurableTaskGrpcWorker createWorker() {
96+
DurableTaskGrpcWorkerBuilder builder = new DurableTaskGrpcWorkerBuilder();
97+
98+
builder.addOrchestration(new TaskOrchestrationFactory() {
99+
@Override
100+
public String getName() { return ORCHESTRATION_NAME; }
101+
102+
@Override
103+
public TaskOrchestration create() {
104+
return ctx -> {
105+
String input = ctx.getInput(String.class);
106+
107+
// Non-replay-safe logger: emits on every replay.
108+
Logger plainLogger = LoggerFactory.getLogger(ReplaySafeLoggingPattern.class);
109+
110+
// Replay-safe logger: emits only when the orchestrator is NOT replaying.
111+
Logger replaySafeLogger = ctx.createReplaySafeLogger(ReplaySafeLoggingPattern.class);
112+
113+
plainLogger.info("[plain] starting orchestration for input='{}'", input);
114+
replaySafeLogger.info("[replay-safe] starting orchestration for input='{}'", input);
115+
116+
String greeting = ctx.callActivity(ECHO_ACTIVITY, "Hello, " + input + "!", String.class).await();
117+
118+
plainLogger.info("[plain] first activity returned '{}'", greeting);
119+
replaySafeLogger.info("[replay-safe] first activity returned '{}'", greeting);
120+
121+
String farewell = ctx.callActivity(ECHO_ACTIVITY, "Goodbye, " + input + "!", String.class).await();
122+
123+
plainLogger.info("[plain] second activity returned '{}'", farewell);
124+
replaySafeLogger.info("[replay-safe] second activity returned '{}'", farewell);
125+
126+
ctx.complete(greeting + " / " + farewell);
127+
};
128+
}
129+
});
130+
131+
builder.addActivity(new TaskActivityFactory() {
132+
@Override
133+
public String getName() { return ECHO_ACTIVITY; }
134+
135+
@Override
136+
public TaskActivity create() {
137+
return ctx -> ctx.getInput(String.class);
138+
}
139+
});
140+
return builder.build();
141+
}
142+
143+
private ReplaySafeLoggingPattern() {
144+
// sample entry point
145+
}
146+
}

0 commit comments

Comments
 (0)