🚦 fix: Downgrade Stale Generation Fence Telemetry (#15452)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Images Build / publish (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run

* fix: downgrade stale generation fence telemetry

* fix: preserve fenced persistence failures

* fix: avoid duplicate fence warnings

* fix: propagate terminal publication fences

* style: format fencing audit changes

* fix: keep successor transitions generation scoped

* fix: bound predecessor handoff retirement

* fix: reconcile discovered successors completely

* fix: preserve successor handoff acknowledgement
This commit is contained in:
Danny Avila 2026-09-01 03:00:28 -04:00 • committed by GitHub
parent 97e1e950ad
commit cdfe54c349
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 685 additions and 80 deletions

View file

@ -38,6 +38,7 @@ import type { SteerContentView } from './SteeringLifecycle';
import type { GenerationJobStore } from '~/app/metrics';
import type * as t from '~/types';
import {
GenerationPublicationFencedError,
JobCreationSupersededError,
JobPredecessorMismatchError,
isPendingActionStale,
@ -587,6 +588,9 @@ interface RuntimeJobState {
* its own abort registration. Keep this runtime's transport callbacks as a
* channel handoff bridge until the successor explicitly releases them. */
replacementTransportHold?: boolean;
/** Stored-terminal discovery proved that this held predecessor needs the
* receipt-aware retirement lifecycle once the successor releases the hold. */
replacementRetirementPending?: boolean;
/**
* Cooperative-seal requests for THIS generation. Armed by `requestPreempt`
* (locally or via a fenced cross-replica publish), polled O(1) by the run's
@ -1121,6 +1125,26 @@ class GenerationJobManagerClass {
this.reconcileInactiveGeneration(streamId, createdAt, currentJob, observedRuntime);
}
private async reconcileDiscoveredSuccessor(
streamId: string,
runtime: RuntimeJobState,
successor: SerializableJobData,
): Promise<void> {
const ownsExactProvider = this.ownedJobs.get(streamId) === runtime.createdAt;
if (!runtime.abortController.signal.aborted) {
runtime.abortController.abort();
}
const abortProofPersisted = await this.persistFencedRuntimeAbortProof(
streamId,
runtime,
ownsExactProvider,
);
if (abortProofPersisted) {
this.reconcileInactiveGeneration(streamId, runtime.createdAt, successor, runtime);
}
this.preserveFencedRuntimeUntilHandoff(streamId, runtime);
}
/** Promotes a terminal claim whose persistence owner disappeared to a conservative
* terminal payload. The store CAS races safely with a slow owner: whichever
* side finalizes first chooses the only payload subscribers may consume. */
@ -1157,6 +1181,11 @@ class GenerationJobManagerClass {
try {
await this.eventTransport.emitDone(jobData.streamId, reconcileEvent, jobData.createdAt);
} catch (err) {
if (err instanceof GenerationPublicationFencedError) {
const currentJob = await this.jobStore.getJob(jobData.streamId);
this.reconcileInactiveGeneration(jobData.streamId, jobData.createdAt, currentJob, runtime);
return currentJob;
}
logger.error(
`[GenerationJobManager] Failed to publish stale terminal-persistence recovery ${jobData.streamId}:`,
err,
@ -1628,10 +1657,12 @@ class GenerationJobManagerClass {
}
} catch (err) {
delivered = false;
logger.error(
`[GenerationJobManager] Failed to notify replaced generation ${streamId}@${predecessorCreatedAt}:`,
err,
);
if (!(err instanceof GenerationPublicationFencedError)) {
logger.error(
`[GenerationJobManager] Failed to notify replaced generation ${streamId}@${predecessorCreatedAt}:`,
err,
);
}
}
return delivered;
}
@ -2471,7 +2502,7 @@ class GenerationJobManagerClass {
// across same-stream replacement. This also lets a predecessor whose
// registration was still becoming ready dispose itself without
// briefly unsubscribing the successor.
this.releaseAbortSubscription(replacedRuntime, true);
this.releaseReplacementTransportHold(streamId, replacedRuntime);
}
// This epoch is not exposed to its controller until the durable bit and
// owner listener agree. A replacement that wins before this write sees
@ -2507,7 +2538,7 @@ class GenerationJobManagerClass {
}
} catch (error) {
if (replacedRuntime != null && replacedRuntime !== runtime) {
this.releaseAbortSubscription(replacedRuntime, true);
this.releaseReplacementTransportHold(streamId, replacedRuntime);
}
// The durable job already exists, but the caller has not received its
// generation identity yet. Finalize that exact epoch here so a controller
@ -3618,7 +3649,11 @@ class GenerationJobManagerClass {
async publishTerminalClaim(
claim: TerminalJobClaim,
finalEvent: t.ServerSentEvent | null,
): Promise<{ finalEvent: t.ServerSentEvent; persistenceFailed: boolean }> {
): Promise<{
finalEvent: t.ServerSentEvent;
persistenceFailed: boolean;
publicationFenced?: true;
}> {
if (!this.terminalClaimRuntimes.has(claim) || claim.persistencePending !== true) {
throw new Error('Terminal persistence claim was not issued by this manager');
}
@ -3676,6 +3711,11 @@ class GenerationJobManagerClass {
} else if (runtime) {
runtime.finalEvent = publicationEvent;
}
const persistenceFailed =
finalEvent == null ||
!durable ||
publicationEvent !== finalEvent ||
('reconcile' in publicationEvent && publicationEvent.reconcile === true);
try {
if (runtime?.createdEventPublication) {
@ -3683,6 +3723,13 @@ class GenerationJobManagerClass {
}
await this.eventTransport.emitDone(streamId, publicationEvent, createdAt);
} catch (publicationError) {
if (publicationError instanceof GenerationPublicationFencedError) {
return {
finalEvent: publicationEvent,
persistenceFailed,
publicationFenced: true,
};
}
logger.error(
`[GenerationJobManager] Failed to publish terminal persistence result ${streamId}:`,
publicationError,
@ -3710,11 +3757,6 @@ class GenerationJobManagerClass {
throw publicationError;
}
const persistenceFailed =
finalEvent == null ||
!durable ||
publicationEvent !== finalEvent ||
('reconcile' in publicationEvent && publicationEvent.reconcile === true);
if (!persistenceFailed && runtime?.startupTelemetry) {
this.recordStartupEvent(runtime, publicationEvent);
}
@ -3776,19 +3818,21 @@ class GenerationJobManagerClass {
}
await this.eventTransport.emitError(streamId, terminalError, createdAt);
} catch (publishError) {
logger.error(
`[GenerationJobManager] Failed to publish terminal error for ${streamId}:`,
publishError,
);
if (runtime && this.runtimeState.get(streamId) === runtime) {
for (const notify of [...runtime.localErrorHandlers]) {
try {
notify(terminalError);
} catch (notifyError) {
logger.error(
`[GenerationJobManager] Failed to notify terminal error for ${streamId}:`,
notifyError,
);
if (!(publishError instanceof GenerationPublicationFencedError)) {
logger.error(
`[GenerationJobManager] Failed to publish terminal error for ${streamId}:`,
publishError,
);
if (runtime && this.runtimeState.get(streamId) === runtime) {
for (const notify of [...runtime.localErrorHandlers]) {
try {
notify(terminalError);
} catch (notifyError) {
logger.error(
`[GenerationJobManager] Failed to notify terminal error for ${streamId}:`,
notifyError,
);
}
}
}
}
@ -4889,11 +4933,27 @@ class GenerationJobManagerClass {
}
let terminalJob = await this.jobStore.getJob(streamId);
if (terminalJob?.createdAt !== runtime.createdAt) {
if (terminalJob) {
await this.reconcileDiscoveredSuccessor(streamId, runtime, terminalJob);
} else {
queueError(TERMINAL_PUBLICATION_RECONNECT_ERROR);
}
return;
}
if (
terminalJob?.terminalPersistencePending === true &&
['complete', 'error', 'aborted'].includes(terminalJob.status)
) {
terminalJob = await this.recoverStaleTerminalPersistence(terminalJob);
if (terminalJob?.createdAt !== runtime.createdAt) {
if (terminalJob) {
await this.reconcileDiscoveredSuccessor(streamId, runtime, terminalJob);
} else {
queueError(TERMINAL_PUBLICATION_RECONNECT_ERROR);
}
return;
}
if (terminalJob?.terminalPersistencePending === true) {
const startedAt =
terminalJob.terminalPersistenceStartedAt ??
@ -6221,38 +6281,46 @@ class GenerationJobManagerClass {
return true;
}
private async persistFencedRuntimeAbortProof(
streamId: string,
runtime: RuntimeJobState,
ownsExactProvider: boolean,
): Promise<boolean> {
const recordAbortAcknowledgement = this.eventTransport.recordAbortAcknowledgement;
if (!this._isRedis || !ownsExactProvider) {
return true;
}
if (recordAbortAcknowledgement == null) {
return false;
}
try {
const confirmed = await recordAbortAcknowledgement.call(
this.eventTransport,
streamId,
runtime.createdAt,
);
if (!confirmed) {
logger.warn(
`[GenerationJobManager] Abort proof was not persisted for fenced generation ${streamId}`,
);
}
return confirmed;
} catch (error) {
logger.error(
`[GenerationJobManager] Failed to persist abort proof for fenced generation ${streamId}:`,
error,
);
return false;
}
}
private recordFencedRuntimeAbortProof(
streamId: string,
runtime: RuntimeJobState,
ownsExactProvider: boolean,
): void {
const recordAbortAcknowledgement = this.eventTransport.recordAbortAcknowledgement;
if (!this._isRedis || recordAbortAcknowledgement == null || !ownsExactProvider) {
return;
}
try {
void recordAbortAcknowledgement
.call(this.eventTransport, streamId, runtime.createdAt)
.then((confirmed) => {
if (!confirmed) {
logger.warn(
`[GenerationJobManager] Abort proof was not persisted for fenced generation ${streamId}`,
);
}
})
.catch((error) => {
logger.error(
`[GenerationJobManager] Failed to persist abort proof for fenced generation ${streamId}:`,
error,
);
});
} catch (error) {
logger.error(
`[GenerationJobManager] Failed to start abort proof for fenced generation ${streamId}:`,
error,
);
}
void this.persistFencedRuntimeAbortProof(streamId, runtime, ownsExactProvider);
}
private cleanupFencedRuntime(streamId: string, runtime: RuntimeJobState): void {
@ -6311,10 +6379,21 @@ class GenerationJobManagerClass {
const ownsExactProvider = this.ownedJobs.get(streamId) === runtime.createdAt;
runtime.abortController.abort();
this.recordFencedRuntimeAbortProof(streamId, runtime, ownsExactProvider);
if (this.shuttingDown) {
this.preserveFencedRuntimeUntilHandoff(streamId, runtime);
}
private preserveFencedRuntimeUntilHandoff(streamId: string, runtime: RuntimeJobState): void {
const currentRuntime = this.runtimeState.get(streamId);
if (
this.shuttingDown ||
(currentRuntime !== runtime &&
(currentRuntime == null || currentRuntime.createdAt <= runtime.createdAt)) ||
this.fencedRuntimeRetirements.has(runtime)
) {
return;
}
if (runtime.replacementTransportHold === true) {
runtime.replacementRetirementPending = true;
return;
}
if (runtime.localErrorHandlers.size === 0) {
@ -6326,6 +6405,15 @@ class GenerationJobManagerClass {
this.scheduleFencedRuntimeRetirement(streamId, runtime, Date.now(), retirement);
}
private releaseReplacementTransportHold(streamId: string, runtime: RuntimeJobState): void {
const retirementPending = runtime.replacementRetirementPending === true;
runtime.replacementRetirementPending = false;
this.releaseAbortSubscription(runtime, true);
if (retirementPending) {
this.preserveFencedRuntimeUntilHandoff(streamId, runtime);
}
}
private isCurrentRuntime(streamId: string, runtime: RuntimeJobState): boolean {
return this.runtimeState.get(streamId) === runtime && !runtime.abortController.signal.aborted;
}
@ -7688,16 +7776,18 @@ class GenerationJobManagerClass {
runtime.approvalExpiryPublished = true;
}
} catch (err) {
logger.error(`[GenerationJobManager] Failed to publish expired approval ${streamId}`, err);
if (runtime?.createdAt === createdAt) {
for (const notify of [...runtime.localErrorHandlers]) {
try {
notify(APPROVAL_EXPIRED_ERROR);
} catch (notifyError) {
logger.error(
`[GenerationJobManager] Failed to notify expired approval ${streamId}`,
notifyError,
);
if (!(err instanceof GenerationPublicationFencedError)) {
logger.error(`[GenerationJobManager] Failed to publish expired approval ${streamId}`, err);
if (runtime?.createdAt === createdAt) {
for (const notify of [...runtime.localErrorHandlers]) {
try {
notify(APPROVAL_EXPIRED_ERROR);
} catch (notifyError) {
logger.error(
`[GenerationJobManager] Failed to notify expired approval ${streamId}`,
notifyError,
);
}
}
}
}
@ -7936,6 +8026,21 @@ class GenerationJobManagerClass {
);
observedRuntime.startupTelemetry?.mark('first_response_event_queued');
} catch (err) {
if (err instanceof GenerationPublicationFencedError) {
const currentJob = await this.jobStore.getJob(streamId);
if (currentJob) {
await this.reconcileDiscoveredSuccessor(streamId, observedRuntime, currentJob);
} else {
this.reconcileInactiveGeneration(
streamId,
observedRuntime.createdAt,
currentJob,
observedRuntime,
);
this.preserveFencedRuntimeUntilHandoff(streamId, observedRuntime);
}
continue;
}
logger.error(`[GenerationJobManager] Failed to notify reaped stream ${streamId}:`, err);
}
}

View file

@ -3,6 +3,7 @@ import type { Redis } from 'ioredis';
import { emitChunkWithReceipt, emitObservedChunk } from '~/stream/internal/chunkPublication';
import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport';
import { RedisEventTransport } from '~/stream/implementations/RedisEventTransport';
import { GenerationPublicationFencedError } from '~/stream/interfaces/IJobStore';
import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore';
import { GenerationJobManagerClass } from '~/stream/GenerationJobManager';
import { createMockPublisher } from './helpers/publisher';
@ -1429,19 +1430,47 @@ describe('RedisEventTransport', () => {
mockPublisher as unknown as Redis,
mockSubscriber as unknown as Redis,
);
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => logger);
const error = jest.spyOn(logger, 'error').mockImplementation(() => logger);
mockPublisher.eval.mockResolvedValueOnce(-1);
await expect(
transport.emitReplacedDoneConfirmed('stale-replacement', { final: true }, 1234, 'wrong'),
).rejects.toThrow('replacement DONE receipt is no longer current');
mockPublisher.eval.mockResolvedValueOnce(-1);
await expect(transport.emitDone('stale-done', { final: true }, 1234)).rejects.toThrow(
'DONE publication was fenced',
);
mockPublisher.eval.mockResolvedValueOnce(-1);
await expect(transport.emitError('stale-error', 'failed', 1234)).rejects.toThrow(
'error publication was fenced',
);
try {
mockPublisher.eval.mockResolvedValueOnce(-1);
await expect(
transport.emitReplacedDoneConfirmed('stale-replacement', { final: true }, 1234, 'wrong'),
).rejects.toThrow('replacement DONE receipt is no longer current');
mockPublisher.eval.mockResolvedValueOnce(-1);
await expect(transport.emitDone('stale-done', { final: true }, 1234)).rejects.toBeInstanceOf(
GenerationPublicationFencedError,
);
mockPublisher.eval.mockResolvedValueOnce(-1);
await expect(transport.emitError('stale-error', 'failed', 1234)).rejects.toBeInstanceOf(
GenerationPublicationFencedError,
);
expect(warn).toHaveBeenNthCalledWith(
1,
'[RedisEventTransport] Skipped stale terminal publication:',
{ streamId: 'stale-done', generationId: 1234, eventType: 'done' },
);
expect(warn).toHaveBeenNthCalledWith(
2,
'[RedisEventTransport] Skipped stale terminal publication:',
{ streamId: 'stale-error', generationId: 1234, eventType: 'error' },
);
expect(error).not.toHaveBeenCalled();
mockPublisher.eval.mockRejectedValueOnce(new Error('Redis unavailable'));
await expect(transport.emitDone('failed-done', { final: true }, 1234)).rejects.toThrow(
'Redis unavailable',
);
expect(error).toHaveBeenCalledWith(
'[RedisEventTransport] Failed to publish done:',
expect.objectContaining({ message: 'Redis unavailable' }),
);
} finally {
warn.mockRestore();
error.mockRestore();
}
transport.destroy();
});

View file

@ -1,8 +1,11 @@
import type { Agents } from 'librechat-data-provider';
import {
GenerationPublicationFencedError,
PAUSE_PERSISTENCE_TIMEOUT_ERROR,
} from '~/stream/interfaces/IJobStore';
import { ApprovalLifecycle, PendingActionExpiredError } from '~/stream/ApprovalLifecycle';
import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport';
import { buildPendingAction, buildToolApprovalPayload } from '~/agents/hitl/policy';
import { PAUSE_PERSISTENCE_TIMEOUT_ERROR } from '~/stream/interfaces/IJobStore';
import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore';
import { GenerationJobManagerClass } from '~/stream/GenerationJobManager';
@ -987,6 +990,22 @@ describe('ApprovalLifecycle via GenerationJobManager.approvals (in-memory)', ()
subscription?.unsubscribe();
});
test('does not invoke local expiry fallback when publication is fenced', async () => {
const streamId = 'stream-expire-publication-fenced';
const job = await manager.createJob(streamId, 'user-1');
const onError = jest.fn();
const subscription = await manager.subscribe(streamId, () => undefined, undefined, onError);
await manager.approvals.pause(streamId, buildAction(streamId));
jest.spyOn(eventTransport, 'emitError').mockImplementation(() => {
throw new GenerationPublicationFencedError('error', streamId, job.createdAt);
});
expect(await manager.expireApproval(streamId)).toBe(true);
expect(onError).not.toHaveBeenCalled();
subscription?.unsubscribe();
});
test('delivers the stored expiry error to a late subscriber', async () => {
const streamId = 'stream-expire-late-subscriber';
await manager.createJob(streamId, 'user-1');

View file

@ -418,4 +418,60 @@ describe('GenerationJobManager - generation abort on reaping', () => {
await manager.destroy();
}
});
it('keeps the predecessor subscription attached when reaper publication is fenced', async () => {
const { GenerationJobManagerClass } = await import('../GenerationJobManager');
const { InMemoryJobStore } = await import('../implementations/InMemoryJobStore');
const { InMemoryEventTransport } = await import('../implementations/InMemoryEventTransport');
const { GenerationPublicationFencedError } = await import('../interfaces/IJobStore');
jest.useFakeTimers();
try {
const store = new InMemoryJobStore({ ttlAfterComplete: 0, staleJobTimeout: 1000 });
const transport = new InMemoryEventTransport();
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore: store, eventTransport: transport, isRedis: false });
manager.initialize();
const streamId = 'replacement-during-reaper-publication';
const predecessor = await manager.createJob(streamId, 'user-1', streamId);
const onDone = jest.fn();
const onError = jest.fn();
const subscription = await manager.subscribe(streamId, () => undefined, onDone, onError);
let successorCreatedAt = 0;
jest.spyOn(transport, 'emitError').mockImplementation(async () => {
const successor = await store.createJob(streamId, 'user-1', streamId);
successorCreatedAt = successor.createdAt;
throw new GenerationPublicationFencedError('error', streamId, predecessor.createdAt);
});
await jest.advanceTimersByTimeAsync(2000);
await (
manager as unknown as {
cleanup: () => Promise<void>;
}
).cleanup();
expect(onError).not.toHaveBeenCalled();
expect(transport.getSubscriberCount(streamId)).toBe(1);
expect(manager.getRuntimeStats().runtimeStateSize).toBe(1);
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(1);
await transport.emitDone(
streamId,
{ final: true, generationCreatedAt: successorCreatedAt },
predecessor.createdAt,
);
expect(onDone).toHaveBeenCalledWith({
final: true,
generationCreatedAt: successorCreatedAt,
});
expect(manager.getRuntimeStats().runtimeStateSize).toBe(0);
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(0);
subscription?.unsubscribe();
await manager.destroy();
} finally {
jest.useRealTimers();
}
});
});

View file

@ -1,3 +1,4 @@
import { logger } from '@librechat/data-schemas';
import type { AbortResult } from '~/stream/interfaces/IJobStore';
import type { AgentStartupTelemetry } from '~/agents/startup';
import type { ServerSentEvent } from '~/types';
@ -14,6 +15,7 @@ import {
import { InMemoryEventTransport } from '~/stream/implementations/InMemoryEventTransport';
import { registerChunkPublicationCapability } from '~/stream/internal/chunkPublication';
import { buildPendingAction, buildToolApprovalPayload } from '~/agents/hitl/policy';
import { GenerationPublicationFencedError } from '~/stream/interfaces/IJobStore';
import { InMemoryJobStore } from '~/stream/implementations/InMemoryJobStore';
function createTelemetry(): jest.Mocked<AgentStartupTelemetry> {
@ -2238,6 +2240,63 @@ describe('GenerationJobManager startup telemetry', () => {
}
});
it('treats a replacement-fenced terminal publication as a superseded outcome', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = new InMemoryEventTransport();
jest.spyOn(eventTransport, 'emitDone').mockImplementation((streamId, _event, generationId) => {
throw new GenerationPublicationFencedError('done', streamId, generationId);
});
const warn = jest.spyOn(logger, 'warn').mockImplementation(() => logger);
const error = jest.spyOn(logger, 'error').mockImplementation(() => logger);
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: false, cleanupOnComplete: true });
manager.initialize();
const streamId = 'stream-terminal-done-publication-fenced';
try {
const job = await manager.createJob(streamId, 'user-1', streamId);
const claim = await manager.claimTerminalJob(streamId, 'complete', undefined, job.createdAt, {
persistencePending: true,
});
expect(claim).not.toBeNull();
const finalEvent = {
final: true,
conversation: { conversationId: streamId },
responseMessage: { messageId: 'response-fenced-final', unfinished: false },
} as const;
await expect(manager.publishTerminalClaim(claim!, finalEvent)).resolves.toEqual({
finalEvent,
persistenceFailed: false,
publicationFenced: true,
});
expect(warn).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
await manager.finishTerminalJob(claim!);
const failedStreamId = `${streamId}-persistence-failed`;
const failedJob = await manager.createJob(failedStreamId, 'user-1', failedStreamId);
const failedClaim = await manager.claimTerminalJob(
failedStreamId,
'complete',
undefined,
failedJob.createdAt,
{ persistencePending: true },
);
expect(failedClaim).not.toBeNull();
await expect(manager.publishTerminalClaim(failedClaim!, null)).resolves.toMatchObject({
persistenceFailed: true,
publicationFenced: true,
});
await manager.finishTerminalJob(failedClaim!);
} finally {
warn.mockRestore();
error.mockRestore();
await manager.destroy();
}
});
it('releases its exact runtime when abort loses to a terminal transition', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const abortDisposer = jest.fn();
@ -2970,6 +3029,31 @@ describe('GenerationJobManager startup telemetry', () => {
await manager.destroy();
});
it('does not invoke local error fallback when terminal error publication is fenced', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = Object.assign(new InMemoryEventTransport(), {
emitError: jest.fn(async (streamId: string, _error: string, generationId?: number) => {
throw new GenerationPublicationFencedError('error', streamId, generationId);
}),
});
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: false, cleanupOnComplete: false });
manager.initialize();
const streamId = 'stream-error-publish-fenced';
const job = await manager.createJob(streamId, 'user-1');
const onError = jest.fn();
const subscription = await manager.subscribe(streamId, () => undefined, undefined, onError);
await expect(
manager.completeJob(streamId, 'stale provider failure', job.createdAt),
).resolves.toBe(true);
expect(onError).not.toHaveBeenCalled();
expect(job.abortController.signal.aborted).toBe(true);
subscription?.unsubscribe();
await manager.destroy();
});
it('finishes abort runtime cleanup but retains its durable final when publication throws', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = Object.assign(new InMemoryEventTransport(), {
@ -3124,6 +3208,279 @@ describe('GenerationJobManager startup telemetry', () => {
await manager.destroy();
});
it('returns the successor when stale terminal recovery publication is fenced', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = new InMemoryEventTransport();
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: false, cleanupOnComplete: false });
manager.initialize();
const streamId = 'stream-terminal-recovery-replaced';
const predecessor = await manager.createJob(streamId, 'user-1', streamId);
const predecessorDone = jest.fn();
const predecessorSubscription = await manager.subscribe(
streamId,
() => undefined,
predecessorDone,
);
await jobStore.transitionStatusAndDrainSteers(streamId, {
from: 'running',
to: 'aborted',
expectCreatedAt: predecessor.createdAt,
patch: {
completedAt: Date.now() - 60_000,
terminalPersistencePending: true,
terminalPersistenceStartedAt: Date.now() - 60_000,
},
});
const finalize = jobStore.finalizeTerminalPersistence.bind(jobStore);
let successorCreatedAt = 0;
jest.spyOn(jobStore, 'finalizeTerminalPersistence').mockImplementation(async (...args) => {
const finalized = await finalize(...args);
const successor = await jobStore.createJob(streamId, 'user-1', streamId);
successorCreatedAt = successor.createdAt;
return finalized;
});
jest.spyOn(eventTransport, 'emitDone').mockImplementation((_streamId, _event, generationId) => {
throw new GenerationPublicationFencedError('done', streamId, generationId);
});
try {
await expect(manager.getJob(streamId)).resolves.toMatchObject({
createdAt: expect.any(Number),
status: 'running',
});
expect((await manager.getJob(streamId))?.createdAt).toBe(successorCreatedAt);
expect(predecessorDone).not.toHaveBeenCalled();
} finally {
predecessorSubscription?.unsubscribe();
await manager.destroy();
}
});
it('reconnect-closes a predecessor instead of delivering a terminal successor', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = new InMemoryEventTransport();
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: false, cleanupOnComplete: false });
manager.initialize();
const streamId = 'stream-terminal-recovery-successor-error';
const predecessor = await manager.createJob(streamId, 'user-1', streamId);
const predecessorDone = jest.fn();
const predecessorError = jest.fn();
const predecessorSubscription = await manager.subscribe(
streamId,
() => undefined,
predecessorDone,
predecessorError,
);
await jobStore.transitionStatusAndDrainSteers(streamId, {
from: 'running',
to: 'aborted',
expectCreatedAt: predecessor.createdAt,
patch: {
completedAt: Date.now() - 60_000,
terminalPersistencePending: true,
terminalPersistenceStartedAt: Date.now() - 60_000,
},
});
const finalize = jobStore.finalizeTerminalPersistence.bind(jobStore);
jest.spyOn(jobStore, 'finalizeTerminalPersistence').mockImplementation(async (...args) => {
const finalized = await finalize(...args);
const successor = await jobStore.createJob(streamId, 'user-1', streamId);
await jobStore.updateJob(
streamId,
{ status: 'error', error: 'successor provider failure', completedAt: Date.now() },
successor.createdAt,
);
return finalized;
});
jest.spyOn(eventTransport, 'emitDone').mockImplementation((_streamId, _event, generationId) => {
throw new GenerationPublicationFencedError('done', streamId, generationId);
});
jest.useFakeTimers();
try {
await jest.advanceTimersByTimeAsync(0);
expect(predecessorDone).not.toHaveBeenCalled();
expect(predecessorError).not.toHaveBeenCalled();
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(1);
await jest.advanceTimersByTimeAsync(
REDIS_REPLACEMENT_HANDOFF_MAX_WAIT_MS + REDIS_EVENT_REORDER_TIMEOUT_MS * 2,
);
expect(predecessorError).toHaveBeenCalledWith(TERMINAL_PUBLICATION_RECONNECT_ERROR);
expect(manager.getRuntimeStats().runtimeStateSize).toBe(0);
} finally {
jest.useRealTimers();
predecessorSubscription?.unsubscribe();
await manager.destroy();
}
});
it('retires a predecessor when a local successor handoff frame is lost', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = new InMemoryEventTransport();
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: false, cleanupOnComplete: false });
manager.initialize();
const streamId = 'stream-local-successor-handoff-lost';
const predecessor = await manager.createJob(streamId, 'user-1', streamId);
const predecessorDone = jest.fn();
const predecessorError = jest.fn();
jest.spyOn(eventTransport, 'emitDone').mockImplementation(() => undefined);
jest.useFakeTimers();
const predecessorSubscription = await manager.subscribe(
streamId,
() => undefined,
predecessorDone,
predecessorError,
);
try {
const successor = await manager.createJob(streamId, 'user-1', streamId);
await jest.advanceTimersByTimeAsync(0);
expect(successor.createdAt).toBeGreaterThan(predecessor.createdAt);
expect(predecessorDone).not.toHaveBeenCalled();
expect(predecessorError).not.toHaveBeenCalled();
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(1);
await jest.advanceTimersByTimeAsync(
REDIS_REPLACEMENT_HANDOFF_MAX_WAIT_MS + REDIS_EVENT_REORDER_TIMEOUT_MS * 2,
);
expect(predecessorError).toHaveBeenCalledWith(TERMINAL_PUBLICATION_RECONNECT_ERROR);
expect(manager.getRuntimeStats()).toMatchObject({
runtimeStateSize: 1,
fencedRuntimeRetirements: 0,
});
await expect(jobStore.getJob(streamId)).resolves.toMatchObject({
createdAt: successor.createdAt,
});
} finally {
jest.useRealTimers();
predecessorSubscription?.unsubscribe();
await manager.destroy();
}
});
it('schedules predecessor retirement after a successor releases its transport hold', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const eventTransport = new InMemoryEventTransport();
let signalHandoffStarted: (() => void) | undefined;
const handoffStarted = new Promise<void>((resolve) => {
signalHandoffStarted = resolve;
});
let releaseHandoff: (() => void) | undefined;
const handoffGate = new Promise<void>((resolve) => {
releaseHandoff = resolve;
});
jest.spyOn(eventTransport, 'emitDone').mockImplementation(async () => {
signalHandoffStarted?.();
await handoffGate;
});
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: false, cleanupOnComplete: false });
manager.initialize();
const streamId = 'stream-successor-transport-hold-retirement';
const predecessor = await manager.createJob(streamId, 'user-1', streamId);
const predecessorError = jest.fn();
jest.useFakeTimers();
const predecessorSubscription = await manager.subscribe(
streamId,
() => undefined,
undefined,
predecessorError,
);
let creatingSuccessor: ReturnType<typeof manager.createJob> | undefined;
try {
creatingSuccessor = manager.createJob(streamId, 'user-1', streamId);
await handoffStarted;
await jest.advanceTimersByTimeAsync(0);
expect(predecessor.abortController.signal.aborted).toBe(true);
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(0);
releaseHandoff?.();
const successor = await creatingSuccessor;
expect(successor.createdAt).toBeGreaterThan(predecessor.createdAt);
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(1);
await jest.advanceTimersByTimeAsync(
REDIS_REPLACEMENT_HANDOFF_MAX_WAIT_MS + REDIS_EVENT_REORDER_TIMEOUT_MS * 2,
);
expect(predecessorError).toHaveBeenCalledWith(TERMINAL_PUBLICATION_RECONNECT_ERROR);
expect(manager.getRuntimeStats()).toMatchObject({
runtimeStateSize: 1,
fencedRuntimeRetirements: 0,
});
} finally {
releaseHandoff?.();
await creatingSuccessor?.catch(() => undefined);
jest.useRealTimers();
predecessorSubscription?.unsubscribe();
await manager.destroy();
}
});
it('retains the abort listener while stored-terminal abort proof is unavailable', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const abortHandlers = new Set<(generationId?: number) => void | boolean>();
let signalProofStarted: (() => void) | undefined;
const proofStarted = new Promise<void>((resolve) => {
signalProofStarted = resolve;
});
let resolveProof: ((confirmed: boolean) => void) | undefined;
const proofResult = new Promise<boolean>((resolve) => {
resolveProof = resolve;
});
const eventTransport = Object.assign(new InMemoryEventTransport(), {
onAbort: jest.fn(async (_streamId: string, handler: (generationId?: number) => boolean) => {
abortHandlers.add(handler);
return () => abortHandlers.delete(handler);
}),
recordAbortAcknowledgement: jest.fn(async () => {
signalProofStarted?.();
return proofResult;
}),
});
const manager = new GenerationJobManagerClass();
manager.configure({ jobStore, eventTransport, isRedis: true, cleanupOnComplete: false });
manager.initialize();
const streamId = 'stream-successor-discovery-abort-proof';
const predecessor = await manager.createJob(streamId, 'user-1', streamId);
const predecessorSubscription = await manager.subscribe(streamId, () => undefined);
const successor = await jobStore.createJob(streamId, 'user-1', streamId);
try {
await proofStarted;
expect(predecessor.abortController.signal.aborted).toBe(true);
expect(abortHandlers).toHaveProperty('size', 1);
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(0);
resolveProof?.(false);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(successor.createdAt).toBeGreaterThan(predecessor.createdAt);
expect(eventTransport.recordAbortAcknowledgement).toHaveBeenCalledWith(
streamId,
predecessor.createdAt,
);
expect(abortHandlers).toHaveProperty('size', 1);
expect([...abortHandlers][0]?.(predecessor.createdAt)).toBe(true);
expect(manager.getRuntimeStats().fencedRuntimeRetirements).toBe(1);
} finally {
resolveProof?.(false);
predecessorSubscription?.unsubscribe();
await manager.destroy();
}
});
it('closes an already-live subscriber when abort finalization storage fails', async () => {
const jobStore = new InMemoryJobStore({ ttlAfterComplete: 60_000 });
const manager = new GenerationJobManagerClass();

View file

@ -13,6 +13,7 @@ import {
REDIS_EVENT_REORDER_TIMEOUT_MS,
} from '~/stream/internal/timing';
import { registerChunkPublicationCapability } from '~/stream/internal/chunkPublication';
import { GenerationPublicationFencedError } from '~/stream/interfaces/IJobStore';
import { instrumentIORedisClient, RedisUseCases } from '~/cache/redisTelemetry';
/**
@ -1335,10 +1336,18 @@ export class RedisEventTransport implements IEventTransport {
true,
);
if (sequence === -1) {
throw new Error('Generation DONE publication was fenced by a replacement');
throw new GenerationPublicationFencedError('done', streamId, generationId);
}
} catch (err) {
logger.error(`[RedisEventTransport] Failed to publish done:`, err);
if (err instanceof GenerationPublicationFencedError) {
logger.warn(`[RedisEventTransport] Skipped stale terminal publication:`, {
streamId,
generationId,
eventType: err.eventType,
});
} else {
logger.error(`[RedisEventTransport] Failed to publish done:`, err);
}
throw err;
}
}
@ -1398,10 +1407,18 @@ export class RedisEventTransport implements IEventTransport {
true,
);
if (sequence === -1) {
throw new Error('Generation error publication was fenced by a replacement');
throw new GenerationPublicationFencedError('error', streamId, generationId);
}
} catch (err) {
logger.error(`[RedisEventTransport] Failed to publish error:`, err);
if (err instanceof GenerationPublicationFencedError) {
logger.warn(`[RedisEventTransport] Skipped stale terminal publication:`, {
streamId,
generationId,
eventType: err.eventType,
});
} else {
logger.error(`[RedisEventTransport] Failed to publish error:`, err);
}
throw err;
}
}

View file

@ -25,6 +25,7 @@ export type {
// Canonical "is this approval live?" predicate — one definition shared by the
// stores, the approval lifecycle, and the status route / message middleware.
export {
GenerationPublicationFencedError,
JobPredecessorMismatchError,
isPendingActionExpired,
isPendingActionStale,

View file

@ -1531,6 +1531,27 @@ export interface IJobStoreV2 extends IJobStore {
clearSteers(streamId: string): Promise<void>;
}
export type GenerationTerminalEventType = 'done' | 'error';
/** A terminal publication lost the generation fence to a replacement. This is
* an expected safety outcome: the successor owns all further stream output. */
export class GenerationPublicationFencedError extends Error {
readonly code = 'GENERATION_PUBLICATION_FENCED';
constructor(
readonly eventType: GenerationTerminalEventType,
readonly streamId: string,
readonly generationId?: number,
) {
super(
eventType === 'done'
? 'Generation DONE publication was fenced by a replacement'
: 'Generation error publication was fenced by a replacement',
);
this.name = 'GenerationPublicationFencedError';
}
}
/**
* Interface for pub/sub event transport.
* Implementations can use EventEmitter, Redis Pub/Sub, etc.