Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions insight-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ application:
- **Emission modes.** `ON_COMPLETE` emits one terminal record; `ON_FAILURE` emits only on terminal
failure; `ON_CHANGE` emits at invocation start, on every operation change, and at invocation end
(matching JS). Non-terminal statuses map to `RUNNING`.
- **Bounded `ON_CHANGE` delivery.** Start and operation-change hooks enqueue complete snapshots
without waiting for exporter I/O. Each invocation keeps a FIFO of up to 16 waiting snapshots plus
one in-flight export. When that bound is reached, only the oldest waiting `RUNNING` snapshot is
dropped; normal bursts are preserved rather than unconditionally coalesced. Invocation end seals
the queue, reserves the final snapshot, drains records in order, and flushes each exporter once
before Lambda can freeze the environment. A late `RUNNING` snapshot cannot follow or overwrite
the invocation-final record.
- **Operation filtering** mirrors JS: the `EXECUTION` pseudo-operation and unnamed operations are
dropped; `TOP_LEVEL` detail drops any operation with a `parentId`; an `OperationOverride.exclude`
drops by name. Operation `result` is included only when an `OperationOverride.withResult`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package software.amazon.lambda.durable.insight;

import java.util.ArrayDeque;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;

/** Serializes ON_CHANGE exports through a bounded, invocation-scoped FIFO. */
final class ExportScheduler {
static final int DEFAULT_CAPACITY = 16;

private static final AtomicInteger THREAD_NUMBER = new AtomicInteger();
// Shared for the Lambda process lifetime. Cached daemon workers are reclaimed after idle periods, while each
// invocation retains its own serial scheduler, bounded queue, and flush barrier.
private static final ExecutorService WORKERS = Executors.newCachedThreadPool(runnable -> {
var thread = new Thread(runnable, "workflow-insight-export-" + THREAD_NUMBER.incrementAndGet());
thread.setDaemon(true);
return thread;
});

private final int capacity;
private final Executor executor;
private final Consumer<WorkflowInsightRecord> export;
private final Runnable flush;
private final Consumer<Throwable> failureHandler;
private final ArrayDeque<WorkflowInsightRecord> queue = new ArrayDeque<>();
private final CompletableFuture<Void> drained = new CompletableFuture<>();

private boolean running;
private boolean sealed;

ExportScheduler(Consumer<WorkflowInsightRecord> export, Runnable flush, Consumer<Throwable> failureHandler) {
this(DEFAULT_CAPACITY, WORKERS, export, flush, failureHandler);
}

ExportScheduler(
int capacity,
Executor executor,
Consumer<WorkflowInsightRecord> export,
Runnable flush,
Consumer<Throwable> failureHandler) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive");
}
this.capacity = capacity;
this.executor = executor;
this.export = export;
this.flush = flush;
this.failureHandler = failureHandler;
}

/** Queues a complete RUNNING snapshot, dropping the oldest waiting snapshot only under backpressure. */
synchronized boolean schedule(WorkflowInsightRecord record) {
if (sealed) {
return false;
}
enqueue(record);
startWorker();
return true;
}

/** Seals this invocation, queues its final snapshot, and returns a future for the ordered flush barrier. */
synchronized CompletableFuture<Void> sealAndDrain(WorkflowInsightRecord finalRecord) {
if (!sealed) {
sealed = true;
if (finalRecord != null) {
enqueue(finalRecord);
}
startWorker();
}
return drained;
}

private void enqueue(WorkflowInsightRecord record) {
if (queue.size() == capacity) {
queue.removeFirst();
}
queue.addLast(record);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review · Finding arf_v1_zd7gncmrres5u3dmq4nazbmvno

[P2] Freeze each record before placing it on the asynchronous queue. Records can contain live mutable values, notably executionResult or objects returned by content/result transforms, while deepCopy() currently occurs only when the worker eventually exports them. Under backpressure, later mutation can therefore change an earlier snapshot and make Workflow Insight disagree with the already-serialized durable result. Deep-copy or otherwise detach mutable record content during enqueue, and add a test that mutates content before a delayed worker runs.

}

private void startWorker() {
if (running) {
return;
}
running = true;
try {
executor.execute(this::pump);
} catch (Throwable t) {
running = false;
reportFailure(t);
if (sealed) {
queue.clear();
drained.complete(null);
Comment on lines +97 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review · Finding arf_v1_yk6awyvhew5xhws3cqn4dgeagn

[P2] Do not report a sealed queue as drained when worker submission fails. If the worker went idle and sealAndDrain() encounters a rejection or thread-creation failure, this branch discards the reserved final record and every queued update, skips exporter flushing, and lets onInvocationEnd() return successfully. Preserve the queue and synchronously drain it at the invocation boundary, or retry submission while leaving drained incomplete; cover rejection specifically on the final submission.

}
}
}

private void pump() {
while (true) {
WorkflowInsightRecord record;
synchronized (this) {
if (!queue.isEmpty()) {
record = queue.removeFirst();
} else if (sealed) {
record = null;
} else {
running = false;
return;
}
}

if (record != null) {
runSafely(() -> export.accept(record));
continue;
}

runSafely(flush);
synchronized (this) {
running = false;
drained.complete(null);
}
return;
}
}

private void runSafely(Runnable action) {
try {
action.run();
} catch (Throwable t) {
reportFailure(t);
}
}

private void reportFailure(Throwable t) {
try {
failureHandler.accept(t);
} catch (Throwable ignored) {
// A scheduler diagnostic must never disrupt durable execution.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
* lifetime of a warm container. Nothing is lost across a resume: the next invocation recreates the same stable start
* time from {@link InvocationInfo#executionStartTime()}, the same sampling decision deterministically from the ARN, and
* the input snapshot from {@link InvocationInfo#executionInput()}.
*
* <p>{@code ON_CHANGE} hooks enqueue complete snapshots into a bounded per-invocation FIFO; they never perform exporter
* I/O on the checkpoint callback thread. The invocation-end hook seals the FIFO with the final snapshot, drains it in
* order, and flushes exporters before removing state. Under backpressure only the oldest waiting snapshot is dropped,
* while the invocation-final snapshot is always retained and exported last.
*/
@Experimental
public final class WorkflowInsight {
Expand All @@ -59,12 +64,14 @@ private static final class ExecutionState {
final Instant startTime;
final ArnParser arn;
final boolean sampledIn;
final ExportScheduler scheduler;
volatile Object cachedInput;

ExecutionState(Instant startTime, ArnParser arn, boolean sampledIn) {
ExecutionState(Instant startTime, ArnParser arn, boolean sampledIn, ExportScheduler scheduler) {
this.startTime = startTime;
this.arn = arn;
this.sampledIn = sampledIn;
this.scheduler = scheduler;
}
}

Expand Down Expand Up @@ -100,8 +107,14 @@ int retainedStateCount() {
}

private ExecutionState getState(String arn, Instant startTime) {
return byArn.computeIfAbsent(
arn, a -> new ExecutionState(startTime, ArnParser.parse(a), shouldSample(a, samplingRate)));
return byArn.computeIfAbsent(arn, a -> {
boolean sampledIn = shouldSample(a, samplingRate);
ExportScheduler scheduler = sampledIn && emitMode == WorkflowInsightConfig.EmitMode.ON_CHANGE
? new ExportScheduler(
this::exportRecord, this::flushExporters, t -> logSafely("export scheduler failed", t))
: null;
return new ExecutionState(startTime, ArnParser.parse(a), sampledIn, scheduler);
});
}

@Override
Expand All @@ -122,8 +135,8 @@ public void onInvocationStart(InvocationInfo info) {
logSafely("failed to snapshot execution input; omitting input", t);
state.cachedInput = null;
}
if (emitMode == WorkflowInsightConfig.EmitMode.ON_CHANGE) {
emit(buildRecord(
if (state.scheduler != null) {
state.scheduler.schedule(buildRecord(
state,
info.durableExecutionArn(),
"RUNNING",
Expand All @@ -148,7 +161,7 @@ public void onOperationChange(OperationChangeInfo info) {
if (state == null || !state.sampledIn) {
return;
}
emit(buildRecord(
state.scheduler.schedule(buildRecord(
state,
info.durableExecutionArn(),
"RUNNING",
Expand All @@ -164,8 +177,9 @@ public void onOperationChange(OperationChangeInfo info) {

@Override
public void onInvocationEnd(InvocationEndInfo info) {
ExecutionState state = null;
try {
ExecutionState state = getState(info.durableExecutionArn(), info.executionStartTime());
state = getState(info.durableExecutionArn(), info.executionStartTime());
String status = mapStatus(info.invocationStatus());
boolean isTerminal = "SUCCEEDED".equals(status) || "FAILED".equals(status);
boolean isFailure = "FAILED".equals(status);
Expand All @@ -184,52 +198,86 @@ public void onInvocationEnd(InvocationEndInfo info) {
}

if (state.sampledIn && shouldEmit) {
emit(buildRecord(
WorkflowInsightRecord record = buildRecord(
state,
info.durableExecutionArn(),
status,
info.operations(),
Instant.now(),
state.cachedInput,
info.executionResult(),
info.executionError()));
info.executionError());
if (state.scheduler != null) {
state.scheduler.sealAndDrain(record).join();
} else {
emitAndFlush(record);
}
}
} catch (Throwable t) {
// A plugin failure at end-of-invocation (record construction, transforms, truncation, export/flush,
// or optional exporter class linkage) must never disrupt durable execution.
logSafely("onInvocationEnd failed", t);
} finally {
// If final record construction failed, still seal and drain any ON_CHANGE records already queued.
if (state != null && state.scheduler != null) {
try {
state.scheduler.sealAndDrain(null).join();
} catch (Throwable t) {
logSafely("failed to drain export scheduler", t);
}
}
// Remove per-execution state on EVERY invocation end, including non-terminal PENDING/RETRYING suspends,
// once any emission work above is done. Nothing durable is lost: the next invocation's onInvocation
// start recreates the stable startTime from InvocationInfo.executionStartTime() (stable across
// resumes),
// the one-time sampling decision deterministically from the ARN, and the input snapshot from
// InvocationInfo.executionInput(). Retaining state instead leaked one entry per suspended execution for
// the lifetime of the warm container. This runs even if emission above threw, so a plugin failure can
// never turn into a state leak.
// resumes), the one-time sampling decision deterministically from the ARN, and the input snapshot from
// InvocationInfo.executionInput().
byArn.remove(info.durableExecutionArn());
}
}

private void emitAndFlush(WorkflowInsightRecord record) {
for (InsightExporter exporter : exporters) {
exportRecord(record, exporter);
flushExporter(exporter);
}
}

/** Serializes each record to every exporter, isolating failures so one exporter never blocks the others. */
private void emit(WorkflowInsightRecord record) {
private void exportRecord(WorkflowInsightRecord record) {
for (InsightExporter exporter : exporters) {
try {
// Give each exporter its own deep copy: truncation returns the original record when it already
// fits, so without this a custom exporter that mutates operations or nested content would corrupt
// every exporter that runs after it.
WorkflowInsightRecord isolated = record.deepCopy();
WorkflowInsightRecord shaped =
Truncation.truncateRecord(isolated, exporter.maxRecordSizeBytes(), exporter::render);
exporter.export(shaped);
exporter.flush();
} catch (Throwable t) {
// Catch Throwable, not just RuntimeException: deep copy, truncation, an exporter's render/export/
// flush, or the linkage of an optional exporter class (a NoClassDefFoundError when the S3 /
// CloudWatch SDK is absent) can each fail with an Error. Isolating every Throwable here guarantees
// one failing exporter cannot block the exporters that run after it, nor disrupt the execution.
logSafely("exporter failed", t);
}
exportRecord(record, exporter);
}
}

private void exportRecord(WorkflowInsightRecord record, InsightExporter exporter) {
try {
// Give each exporter its own deep copy: truncation returns the original record when it already fits,
// so without this a custom exporter that mutates operations or nested content would corrupt every
// exporter that runs after it.
WorkflowInsightRecord isolated = record.deepCopy();
WorkflowInsightRecord shaped =
Truncation.truncateRecord(isolated, exporter.maxRecordSizeBytes(), exporter::render);
exporter.export(shaped);
} catch (Throwable t) {
// Catch Throwable, not just RuntimeException: deep copy, truncation, an exporter's render/export, or
// optional exporter class linkage can each fail with an Error. Isolating every Throwable here
// guarantees one failing exporter cannot block the exporters after it or disrupt the execution.
logSafely("exporter failed", t);
}
}

/** Flushes each exporter once at the invocation boundary after all scheduled records have been exported. */
private void flushExporters() {
for (InsightExporter exporter : exporters) {
flushExporter(exporter);
}
}

private void flushExporter(InsightExporter exporter) {
try {
exporter.flush();
} catch (Throwable t) {
logSafely("exporter flush failed", t);
}
}

Expand Down
Loading
Loading