diff --git a/CHANGELOG.md b/CHANGELOG.md index 160e790f..da1ef398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## Unreleased +* 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. +* **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. ## v1.9.0 * Fix entity locking deserialization and add Jackson support for EntityInstanceId/EntityMetadata ([#281](https://github.com/microsoft/durabletask-java/pull/281)) diff --git a/client/build.gradle b/client/build.gradle index b7839ea3..e0bbd87a 100644 --- a/client/build.gradle +++ b/client/build.gradle @@ -52,6 +52,11 @@ dependencies { implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}" implementation "io.opentelemetry:opentelemetry-api:${openTelemetryVersion}" implementation "io.opentelemetry:opentelemetry-context:${openTelemetryVersion}" + // slf4j-api is `api`-scoped because org.slf4j.Logger and org.slf4j.ILoggerFactory appear + // on the public TaskOrchestrationContext surface. The version is the declared floor (2.0.0, + // required for the SLF4J 2.x fluent API and NOPLoggingEventBuilder); downstream consumers + // are free to resolve a newer 2.0.x patch. + api "org.slf4j:slf4j-api:2.0.0" testImplementation "io.opentelemetry:opentelemetry-sdk:${openTelemetryVersion}" testImplementation "io.opentelemetry:opentelemetry-sdk-trace:${openTelemetryVersion}" @@ -59,6 +64,9 @@ dependencies { testImplementation project(':azuremanaged') testImplementation "com.azure:azure-core:${azureCoreVersion}" testImplementation "com.azure:azure-identity:${azureIdentityVersion}" + testImplementation 'org.mockito:mockito-core:5.21.0' + testImplementation 'org.mockito:mockito-junit-jupiter:5.21.0' + testImplementation "org.slf4j:slf4j-simple:2.0.16" testImplementation "io.grpc:grpc-inprocess:${grpcVersion}" } diff --git a/client/src/main/java/com/microsoft/durabletask/ReplaySafeLogger.java b/client/src/main/java/com/microsoft/durabletask/ReplaySafeLogger.java new file mode 100644 index 00000000..7b788a44 --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/ReplaySafeLogger.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask; + +import org.slf4j.Logger; +import org.slf4j.Marker; +import org.slf4j.event.Level; +import org.slf4j.spi.LoggingEventBuilder; +import org.slf4j.spi.NOPLoggingEventBuilder; + +/** + * An SLF4J {@link Logger} wrapper that suppresses log output during orchestration replay. + * + *
Traditional logging methods ({@code info}, {@code debug}, etc.) and the SLF4J 2.x fluent API + * ({@code atInfo()}, {@code atDebug()}, etc.) are both gated on + * {@link TaskOrchestrationContext#getIsReplaying()}. The {@code isXxxEnabled()} family of methods + * always passes through to the underlying logger unchanged. + * + *
This mirrors the {@code ReplaySafeLogger} nested class in the modern .NET + * {@code TaskOrchestrationContext}. + * + *
Note on overloads: The {@code (String, Object, Object)} and {@code (String, Object...)} + * overloads at each log level are both defined by the {@link Logger} interface. SLF4J provides the + * explicit 2-arg overload to avoid varargs array allocation in the common case. Java's overload + * resolution (JLS §15.12.2) always prefers the non-varargs form, so dispatch is unambiguous despite + * the apparent overlap. This wrapper delegates each overload to the identical overload on the inner + * logger. + */ +@SuppressWarnings("java:S2177") // Required SLF4J Logger overload set; cannot be changed in this adapter. +final class ReplaySafeLogger implements Logger { + + private final TaskOrchestrationContext context; + private final Logger inner; + + ReplaySafeLogger(TaskOrchestrationContext context, Logger inner) { + Helpers.throwIfArgumentNull(context, "context"); + Helpers.throwIfArgumentNull(inner, "inner"); + this.context = context; + this.inner = inner; + } + + // ----------------------------------------------------------------------- + // getName — always pass through + // ----------------------------------------------------------------------- + + @Override + public String getName() { + return inner.getName(); + } + + // ----------------------------------------------------------------------- + // isXxxEnabled — always pass through (matches .NET IsEnabled behavior) + // ----------------------------------------------------------------------- + + @Override public boolean isTraceEnabled() { return inner.isTraceEnabled(); } + @Override public boolean isTraceEnabled(Marker marker) { return inner.isTraceEnabled(marker); } + @Override public boolean isDebugEnabled() { return inner.isDebugEnabled(); } + @Override public boolean isDebugEnabled(Marker marker) { return inner.isDebugEnabled(marker); } + @Override public boolean isInfoEnabled() { return inner.isInfoEnabled(); } + @Override public boolean isInfoEnabled(Marker marker) { return inner.isInfoEnabled(marker); } + @Override public boolean isWarnEnabled() { return inner.isWarnEnabled(); } + @Override public boolean isWarnEnabled(Marker marker) { return inner.isWarnEnabled(marker); } + @Override public boolean isErrorEnabled() { return inner.isErrorEnabled(); } + @Override public boolean isErrorEnabled(Marker marker) { return inner.isErrorEnabled(marker); } + + // ----------------------------------------------------------------------- + // SLF4J 2.x fluent API — gate via makeLoggingEventBuilder + // + // atInfo(), atDebug(), atWarn(), atError(), atTrace(), atLevel() are + // default methods on Logger that all delegate to makeLoggingEventBuilder(). + // They bypass the traditional info/debug/warn/error/trace methods + // entirely, so we MUST override this single entry point to prevent + // fluent-API calls from escaping replay safety. + // ----------------------------------------------------------------------- + + @Override + public LoggingEventBuilder makeLoggingEventBuilder(Level level) { + if (context.getIsReplaying()) { + return NOPLoggingEventBuilder.singleton(); + } + return inner.makeLoggingEventBuilder(level); + } + + // ----------------------------------------------------------------------- + // trace — gated on !isReplaying + // ----------------------------------------------------------------------- + + @Override public void trace(String msg) { if (!context.getIsReplaying()) inner.trace(msg); } + @Override public void trace(String format, Object arg) { if (!context.getIsReplaying()) inner.trace(format, arg); } + @Override public void trace(String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.trace(format, arg1, arg2); } + @Override public void trace(String format, Object... arguments) { if (!context.getIsReplaying()) inner.trace(format, arguments); } + @Override public void trace(String msg, Throwable t) { if (!context.getIsReplaying()) inner.trace(msg, t); } + @Override public void trace(Marker marker, String msg) { if (!context.getIsReplaying()) inner.trace(marker, msg); } + @Override public void trace(Marker marker, String format, Object arg) { if (!context.getIsReplaying()) inner.trace(marker, format, arg); } + @Override public void trace(Marker marker, String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.trace(marker, format, arg1, arg2); } + @Override public void trace(Marker marker, String format, Object... argArray) { if (!context.getIsReplaying()) inner.trace(marker, format, argArray); } + @Override public void trace(Marker marker, String msg, Throwable t) { if (!context.getIsReplaying()) inner.trace(marker, msg, t); } + + // ----------------------------------------------------------------------- + // debug — gated on !isReplaying + // ----------------------------------------------------------------------- + + @Override public void debug(String msg) { if (!context.getIsReplaying()) inner.debug(msg); } + @Override public void debug(String format, Object arg) { if (!context.getIsReplaying()) inner.debug(format, arg); } + @Override public void debug(String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.debug(format, arg1, arg2); } + @Override public void debug(String format, Object... arguments) { if (!context.getIsReplaying()) inner.debug(format, arguments); } + @Override public void debug(String msg, Throwable t) { if (!context.getIsReplaying()) inner.debug(msg, t); } + @Override public void debug(Marker marker, String msg) { if (!context.getIsReplaying()) inner.debug(marker, msg); } + @Override public void debug(Marker marker, String format, Object arg) { if (!context.getIsReplaying()) inner.debug(marker, format, arg); } + @Override public void debug(Marker marker, String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.debug(marker, format, arg1, arg2); } + @Override public void debug(Marker marker, String format, Object... arguments) { if (!context.getIsReplaying()) inner.debug(marker, format, arguments); } + @Override public void debug(Marker marker, String msg, Throwable t) { if (!context.getIsReplaying()) inner.debug(marker, msg, t); } + + // ----------------------------------------------------------------------- + // info — gated on !isReplaying + // ----------------------------------------------------------------------- + + @Override public void info(String msg) { if (!context.getIsReplaying()) inner.info(msg); } + @Override public void info(String format, Object arg) { if (!context.getIsReplaying()) inner.info(format, arg); } + @Override public void info(String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.info(format, arg1, arg2); } + @Override public void info(String format, Object... arguments) { if (!context.getIsReplaying()) inner.info(format, arguments); } + @Override public void info(String msg, Throwable t) { if (!context.getIsReplaying()) inner.info(msg, t); } + @Override public void info(Marker marker, String msg) { if (!context.getIsReplaying()) inner.info(marker, msg); } + @Override public void info(Marker marker, String format, Object arg) { if (!context.getIsReplaying()) inner.info(marker, format, arg); } + @Override public void info(Marker marker, String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.info(marker, format, arg1, arg2); } + @Override public void info(Marker marker, String format, Object... arguments) { if (!context.getIsReplaying()) inner.info(marker, format, arguments); } + @Override public void info(Marker marker, String msg, Throwable t) { if (!context.getIsReplaying()) inner.info(marker, msg, t); } + + // ----------------------------------------------------------------------- + // warn — gated on !isReplaying + // ----------------------------------------------------------------------- + + @Override public void warn(String msg) { if (!context.getIsReplaying()) inner.warn(msg); } + @Override public void warn(String format, Object arg) { if (!context.getIsReplaying()) inner.warn(format, arg); } + @Override public void warn(String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.warn(format, arg1, arg2); } + @Override public void warn(String format, Object... arguments) { if (!context.getIsReplaying()) inner.warn(format, arguments); } + @Override public void warn(String msg, Throwable t) { if (!context.getIsReplaying()) inner.warn(msg, t); } + @Override public void warn(Marker marker, String msg) { if (!context.getIsReplaying()) inner.warn(marker, msg); } + @Override public void warn(Marker marker, String format, Object arg) { if (!context.getIsReplaying()) inner.warn(marker, format, arg); } + @Override public void warn(Marker marker, String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.warn(marker, format, arg1, arg2); } + @Override public void warn(Marker marker, String format, Object... arguments) { if (!context.getIsReplaying()) inner.warn(marker, format, arguments); } + @Override public void warn(Marker marker, String msg, Throwable t) { if (!context.getIsReplaying()) inner.warn(marker, msg, t); } + + // ----------------------------------------------------------------------- + // error — gated on !isReplaying + // ----------------------------------------------------------------------- + + @Override public void error(String msg) { if (!context.getIsReplaying()) inner.error(msg); } + @Override public void error(String format, Object arg) { if (!context.getIsReplaying()) inner.error(format, arg); } + @Override public void error(String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.error(format, arg1, arg2); } + @Override public void error(String format, Object... arguments) { if (!context.getIsReplaying()) inner.error(format, arguments); } + @Override public void error(String msg, Throwable t) { if (!context.getIsReplaying()) inner.error(msg, t); } + @Override public void error(Marker marker, String msg) { if (!context.getIsReplaying()) inner.error(marker, msg); } + @Override public void error(Marker marker, String format, Object arg) { if (!context.getIsReplaying()) inner.error(marker, format, arg); } + @Override public void error(Marker marker, String format, Object arg1, Object arg2) { if (!context.getIsReplaying()) inner.error(marker, format, arg1, arg2); } + @Override public void error(Marker marker, String format, Object... arguments) { if (!context.getIsReplaying()) inner.error(marker, format, arguments); } + @Override public void error(Marker marker, String msg, Throwable t) { if (!context.getIsReplaying()) inner.error(marker, msg, t); } +} diff --git a/client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggerFactory.java b/client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggerFactory.java new file mode 100644 index 00000000..081b811b --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggerFactory.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask; + +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; + +/** + * An {@link ILoggerFactory} that produces replay-safe loggers backed by the context's + * underlying {@link TaskOrchestrationContext#getLoggerFactory() logger factory}. + * + *
Mirrors the {@code ReplaySafeLoggerFactoryImpl} nested class in the modern .NET + * {@code TaskOrchestrationContext}. + */ +final class ReplaySafeLoggerFactory implements ILoggerFactory { + + private final TaskOrchestrationContext context; + + ReplaySafeLoggerFactory(TaskOrchestrationContext context) { + Helpers.throwIfArgumentNull(context, "context"); + this.context = context; + } + + /** + * Returns the factory underlying this wrapper. Used by {@link ReplaySafeLoggers#unwrap} + * to walk past nested replay-safe wrappers (wrapper-context delegation pattern). + */ + ILoggerFactory underlying() { + return context.getLoggerFactory(); + } + + @Override + public Logger getLogger(String name) { + Helpers.throwIfArgumentNullOrWhiteSpace(name, "name"); + return new ReplaySafeLogger(context, ReplaySafeLoggers.unwrap(context).getLogger(name)); + } +} diff --git a/client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggers.java b/client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggers.java new file mode 100644 index 00000000..9f1546ba --- /dev/null +++ b/client/src/main/java/com/microsoft/durabletask/ReplaySafeLoggers.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.microsoft.durabletask; + +import org.slf4j.ILoggerFactory; + +/** + * Utility class for unwrapping nested {@link ReplaySafeLoggerFactory} instances. + * + *
Mirrors {@code TaskOrchestrationContext.GetUnwrappedLoggerFactory()} in the modern .NET SDK.
+ * When a wrapper context delegates {@code getLoggerFactory()} to
+ * {@code inner.getReplaySafeLoggerFactory()}, the returned factory is already a
+ * {@link ReplaySafeLoggerFactory}. Without unwrapping, loggers produced from it would be
+ * double-wrapped with redundant replay-safe checks.
+ */
+final class ReplaySafeLoggers {
+
+ private static final int MAX_UNWRAP_DEPTH = 10;
+
+ private ReplaySafeLoggers() {
+ // utility class
+ }
+
+ /**
+ * Returns the underlying {@link ILoggerFactory} for the given context, walking past any
+ * nested {@link ReplaySafeLoggerFactory} wrappers.
+ *
+ * @param context the orchestration context whose logger factory to unwrap
+ * @return the first non-replay-safe-wrapped factory in the chain
+ * @throws IllegalStateException if more than {@value #MAX_UNWRAP_DEPTH} levels of wrapping
+ * are encountered (cycle protection)
+ */
+ static ILoggerFactory unwrap(TaskOrchestrationContext context) {
+ Helpers.throwIfArgumentNull(context, "context");
+ ILoggerFactory factory = context.getLoggerFactory();
+ if (factory == null) {
+ throw new IllegalStateException(
+ "getLoggerFactory() returned null on context of type " +
+ context.getClass().getName() +
+ ". Override TaskOrchestrationContext.getLoggerFactory() to return a non-null " +
+ "ILoggerFactory (typically inner.getReplaySafeLoggerFactory() for wrapper contexts).");
+ }
+ int depth = 0;
+ while (factory instanceof ReplaySafeLoggerFactory) {
+ if (++depth > MAX_UNWRAP_DEPTH) {
+ throw new IllegalStateException(
+ "Maximum unwrap depth exceeded while resolving the underlying ILoggerFactory. " +
+ "Ensure the wrapper's getLoggerFactory() delegates to the inner context's " +
+ "getReplaySafeLoggerFactory() (e.g., 'inner.getReplaySafeLoggerFactory()'), " +
+ "not 'this.getReplaySafeLoggerFactory()'.");
+ }
+ factory = ((ReplaySafeLoggerFactory) factory).underlying();
+ }
+ return factory;
+ }
+}
diff --git a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java
index e94acde4..c9143315 100644
--- a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java
+++ b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java
@@ -13,6 +13,10 @@
import java.util.UUID;
import javax.annotation.Nonnull;
+import org.slf4j.ILoggerFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
/**
* Used by orchestrators to perform actions such as scheduling tasks, durable timers, waiting for external events,
* and for getting basic information about the current orchestration.
@@ -855,4 +859,85 @@ default Task This method is an extension point for wrapper-context implementations, not a
+ * convenience getter for orchestrator code. Orchestrator code should always use
+ * {@link #createReplaySafeLogger(String)} / {@link #createReplaySafeLogger(Class)} or
+ * {@link #getReplaySafeLoggerFactory()} — never call {@code getLoggerFactory()}
+ * directly to obtain a logger, as the returned factory produces non-replay-safe
+ * loggers that will emit duplicate messages on every replay.
+ *
+ * This is the Java analog of the .NET SDK's {@code protected abstract ILoggerFactory
+ * LoggerFactory} property. Java interfaces cannot express {@code protected}, so this method
+ * is exposed as a {@code public default} returning the SLF4J global
+ * {@link LoggerFactory#getILoggerFactory()}. Wrapper contexts should override it
+ * — for example, a wrapper that wants its own logging to also be replay-safe must
+ * return the inner context's {@link #getReplaySafeLoggerFactory()}:
+ * To prevent double-wrapping, the SDK unwraps any nested factory returned from this
+ * method that is itself a replay-safe wrapper.
+ *
+ * @return the {@code ILoggerFactory} backing logger creation for this context; must not
+ * be {@code null}. Returning {@code null} from an override causes
+ * {@link #createReplaySafeLogger(String)} and {@link #getReplaySafeLoggerFactory()}
+ * to throw {@link IllegalStateException}.
+ */
+ @Nonnull
+ default ILoggerFactory getLoggerFactory() {
+ return LoggerFactory.getILoggerFactory();
+ }
+
+ /**
+ * Returns an SLF4J {@link Logger} that is replay-safe: logging methods are no-ops while the
+ * orchestrator is replaying history, and forward to the underlying logger otherwise.
+ * {@code isXxxEnabled()} always passes through unchanged.
+ *
+ * The underlying logger is resolved via {@link #getLoggerFactory()}.
+ *
+ * Requires {@code slf4j-api} version 2.0.0 or later on the classpath.
+ *
+ * @param name the SLF4J logger name (category)
+ * @return a replay-safe SLF4J {@code Logger}
+ */
+ default Logger createReplaySafeLogger(String name) {
+ Helpers.throwIfArgumentNullOrWhiteSpace(name, "name");
+ return new ReplaySafeLogger(this, ReplaySafeLoggers.unwrap(this).getLogger(name));
+ }
+
+ /**
+ * Returns an SLF4J {@link Logger} that is replay-safe, using the fully-qualified class name
+ * as the logger category.
+ *
+ * @param categoryType the class whose name to use as the logger category
+ * @return a replay-safe SLF4J {@code Logger}
+ * @see #createReplaySafeLogger(String)
+ */
+ default Logger createReplaySafeLogger(Class> categoryType) {
+ Helpers.throwIfArgumentNull(categoryType, "categoryType");
+ return new ReplaySafeLogger(this, ReplaySafeLoggers.unwrap(this).getLogger(categoryType.getName()));
+ }
+
+ /**
+ * Returns an {@link ILoggerFactory} that produces replay-safe loggers backed by
+ * {@link #getLoggerFactory()}. Mirrors the {@code ReplaySafeLoggerFactory} property in the
+ * modern .NET SDK.
+ *
+ * Implementations are expected to cache the returned factory for the lifetime of the
+ * context. The default implementation allocates on every call; the concrete runtime
+ * implementation overrides this to cache.
+ *
+ * @return an {@code ILoggerFactory} wrapping each created logger with replay-safe semantics
+ */
+ default ILoggerFactory getReplaySafeLoggerFactory() {
+ return new ReplaySafeLoggerFactory(this);
+ }
}
diff --git a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java
index 8e60080e..59fc89d6 100644
--- a/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java
+++ b/client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java
@@ -14,6 +14,8 @@
import com.microsoft.durabletask.implementation.protobuf.OrchestratorService.ScheduleTaskAction.Builder;
import com.microsoft.durabletask.util.UUIDGenerator;
+import org.slf4j.ILoggerFactory;
+
import javax.annotation.Nullable;
import java.time.Duration;
import java.time.Instant;
@@ -130,6 +132,9 @@ private class ContextImplTask implements TaskOrchestrationContext {
private String version;
private String defaultVersion;
+ // Replay-safe logger factory cache (matches .NET ReplaySafeLoggerFactory caching)
+ private ReplaySafeLoggerFactory cachedReplaySafeLoggerFactory;
+
// LinkedHashMap to maintain insertion order when returning the list of pending actions
private final LinkedHashMap An orchestrator replays its history every time it resumes. A normal SLF4J logger therefore
+ * emits the same message multiple times — once per replay. A logger obtained from
+ * {@link com.microsoft.durabletask.TaskOrchestrationContext#createReplaySafeLogger(Class)}
+ * suppresses output during replay and logs only on the first (non-replay) execution.
+ *
+ * This sample runs an orchestrator that calls two activities. Each activity call causes the
+ * orchestrator to yield and replay on resume, so the messages logged via the standard logger
+ * appear more than once while the replay-safe logger's messages appear exactly once.
+ *
+ * Run it like any other sample (requires a Durable Task sidecar / DTS emulator on the default
+ * gRPC endpoint):
+ *
+ * @Override
+ * public ILoggerFactory getLoggerFactory() {
+ * return inner.getReplaySafeLoggerFactory();
+ * }
+ *
+ *
+ * > allOf(List
{@code
+ * ./gradlew :samples:runReplaySafeLoggingPattern
+ * }
+ *
+ * Wrapper-context pattern (.NET parity)
+ * If you wrap {@code TaskOrchestrationContext} in your own type and want logging emitted from
+ * the wrapper to also be replay-safe, override {@code getLoggerFactory()} to return the inner
+ * context's {@code getReplaySafeLoggerFactory()}:
+ * {@code
+ * final class LoggingContext implements TaskOrchestrationContext {
+ * private final TaskOrchestrationContext inner;
+ * LoggingContext(TaskOrchestrationContext inner) { this.inner = inner; }
+ *
+ * @Override
+ * public ILoggerFactory getLoggerFactory() {
+ * return inner.getReplaySafeLoggerFactory(); // SDK unwraps to avoid double-wrapping
+ * }
+ * // ... forward all other methods to `inner` ...
+ * }
+ * }
+ * Implementing the full wrapper requires forwarding every abstract method on the interface, so
+ * it is omitted here for brevity. The mechanism is the same as the .NET SDK's
+ * {@code protected override ILoggerFactory LoggerFactory => inner.ReplaySafeLoggerFactory;}.
+ */
+final class ReplaySafeLoggingPattern {
+
+ private static final String ORCHESTRATION_NAME = "ReplaySafeLoggingOrchestration";
+ private static final String ECHO_ACTIVITY = "Echo";
+
+ public static void main(String[] args) throws IOException, InterruptedException, TimeoutException {
+ DurableTaskGrpcWorker worker = createWorker();
+ worker.start();
+
+ try {
+ DurableTaskClient client = new DurableTaskGrpcClientBuilder().build();
+
+ String instanceId = client.scheduleNewOrchestrationInstance(
+ ORCHESTRATION_NAME,
+ new NewOrchestrationInstanceOptions().setInput("Seattle"));
+ System.out.printf("Started new orchestration instance: %s%n", instanceId);
+
+ OrchestrationMetadata completed = client.waitForInstanceCompletion(
+ instanceId,
+ Duration.ofSeconds(30),
+ true);
+
+ System.out.printf("Orchestration completed: %s%n", completed.getRuntimeStatus());
+ System.out.printf("Output: %s%n", completed.readOutputAs(String.class));
+ System.out.println();
+ System.out.println(
+ "Note: the non-replay-safe logger's messages should appear multiple times " +
+ "(once per replay), while the replay-safe logger's messages appear once.");
+ } finally {
+ worker.stop();
+ }
+ }
+
+ private static DurableTaskGrpcWorker createWorker() {
+ DurableTaskGrpcWorkerBuilder builder = new DurableTaskGrpcWorkerBuilder();
+
+ builder.addOrchestration(new TaskOrchestrationFactory() {
+ @Override
+ public String getName() { return ORCHESTRATION_NAME; }
+
+ @Override
+ public TaskOrchestration create() {
+ return ctx -> {
+ String input = ctx.getInput(String.class);
+
+ // Non-replay-safe logger: emits on every replay.
+ Logger plainLogger = LoggerFactory.getLogger(ReplaySafeLoggingPattern.class);
+
+ // Replay-safe logger: emits only when the orchestrator is NOT replaying.
+ Logger replaySafeLogger = ctx.createReplaySafeLogger(ReplaySafeLoggingPattern.class);
+
+ plainLogger.info("[plain] starting orchestration for input='{}'", input);
+ replaySafeLogger.info("[replay-safe] starting orchestration for input='{}'", input);
+
+ String greeting = ctx.callActivity(ECHO_ACTIVITY, "Hello, " + input + "!", String.class).await();
+
+ plainLogger.info("[plain] first activity returned '{}'", greeting);
+ replaySafeLogger.info("[replay-safe] first activity returned '{}'", greeting);
+
+ String farewell = ctx.callActivity(ECHO_ACTIVITY, "Goodbye, " + input + "!", String.class).await();
+
+ plainLogger.info("[plain] second activity returned '{}'", farewell);
+ replaySafeLogger.info("[replay-safe] second activity returned '{}'", farewell);
+
+ ctx.complete(greeting + " / " + farewell);
+ };
+ }
+ });
+
+ builder.addActivity(new TaskActivityFactory() {
+ @Override
+ public String getName() { return ECHO_ACTIVITY; }
+
+ @Override
+ public TaskActivity create() {
+ return ctx -> ctx.getInput(String.class);
+ }
+ });
+ return builder.build();
+ }
+
+ private ReplaySafeLoggingPattern() {
+ // sample entry point
+ }
+}