feat(server): queued-only server-route dispatch (#23134)
## Context
Follow-up to the incident where 500s and latency spiked around 6pm until
the Recall webhook was disabled. Server routes
(`/webhooks/server/:resolverUid`) ran the resolver **and** the target
logic function synchronously inside the API request, so any handler
throw became a 500 and any slowdown past Svix's delivery timeout marked
the delivery failed — Svix redelivered, feeding load back into the API
in a self-sustaining storm.
## What changed
- Every server-route request acks with **202 `{ queued: true }`** as
soon as the resolver returns; the target runs on `logicFunctionQueue`.
Signature verification stays synchronous in the resolver and still
rejects with a non-2xx. External senders never observe target latency or
failures.
- The resolver contract is unchanged from main: `{ workspaceId,
targetLogicFunctionUniversalIdentifier, payload? }`.
- The target lookup still happens synchronously before enqueueing,
scoped to the resolver's application registration, so unknown targets
404 as before.
- Endpoints whose caller must read the response body (challenge
handshakes, Slack commands) should use `httpRouteTriggerSettings`
routes.
Final diff is 4 files: the server-route service, its spec, the
integration spec, and the docs page. Trigger jobs, fan-out, message
queue, shared types, and SDK are all untouched.
## Tests
- `server-route-trigger.service.spec.ts`: 202 ack + enqueue,
unknown-target 404 without enqueue, resolver auth/contract/error
mapping.
- Integration: `server-route-trigger-authorization.integration-spec.ts`
asserts the 202 queued ack (run locally against a seeded DB, green).
- `typecheck` + `lint:diff-with-main` + `oxfmt` clean.
## Notes
- **Breaking for existing server-route resolvers**: responses are always
202; the target's return value no longer reaches the caller. Existing
resolvers returning response bodies must move those endpoints to
`httpRouteTriggerSettings`.
- A queued target's handler failure is recorded in execution logs but
not retried (same as other queue-executed functions today); retry
semantics are deliberately out of scope here.
- Follow-up candidates: retry-on-failure semantics for queued
executions, `addBulk` for single-round-trip fan-out, declarative
signature verification to take resolver code out of the request path,
moving the call-recorder 250s artifacts import off the API request path.
- Companion PR #23135 (call-recorder): no app change needed for dispatch
— queued dispatch applies by default.
This commit is contained in:
+54
-82
@@ -7,6 +7,8 @@ import {
|
||||
LogicFunctionExecutionExceptionCode,
|
||||
type LogicFunctionExecutorService,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { LogicFunctionTriggerJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { ServerRouteTriggerExceptionCode } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
|
||||
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
|
||||
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
@@ -72,6 +74,7 @@ describe('ServerRouteTriggerService', () => {
|
||||
let logicFunctionExecutorService: jest.Mocked<
|
||||
Pick<LogicFunctionExecutorService, 'execute'>
|
||||
>;
|
||||
let messageQueueService: jest.Mocked<Pick<MessageQueueService, 'add'>>;
|
||||
|
||||
let resolverRow: unknown;
|
||||
let queryBuilder: QueryBuilderMock;
|
||||
@@ -101,54 +104,57 @@ describe('ServerRouteTriggerService', () => {
|
||||
createQueryBuilder: jest.fn(() => queryBuilder),
|
||||
findOne: jest
|
||||
.fn()
|
||||
// resolver lookup inside runFunction
|
||||
// resolver lookup
|
||||
.mockResolvedValueOnce({ id: 'resolver-id' })
|
||||
// target lookup inside runFunction
|
||||
// target lookup before enqueueing
|
||||
.mockResolvedValueOnce({ id: 'target-id' }),
|
||||
};
|
||||
logicFunctionExecutorService = {
|
||||
execute: jest
|
||||
.fn()
|
||||
// resolver returns { workspaceId, targetLogicFunctionUniversalIdentifier, payload }
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
workspaceId: 'target-ws',
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
payload: { from: 'resolver' },
|
||||
}),
|
||||
)
|
||||
// target returns the final response body
|
||||
.mockResolvedValueOnce(buildExecuteResult({ ok: true })),
|
||||
execute: jest.fn().mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
workspaceId: 'target-ws',
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
payload: { from: 'resolver' },
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
messageQueueService = {
|
||||
add: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
service = new ServerRouteTriggerService(
|
||||
logicFunctionRepository as unknown as Repository<LogicFunctionEntity>,
|
||||
logicFunctionExecutorService as unknown as LogicFunctionExecutorService,
|
||||
messageQueueService as unknown as MessageQueueService,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the resolver in the owner workspace then the resolver-named target in the resolved workspace', async () => {
|
||||
it('runs the resolver synchronously, enqueues the target, and acks with 202', async () => {
|
||||
const result = await handle();
|
||||
|
||||
expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(logicFunctionExecutorService.execute).toHaveBeenCalledTimes(1);
|
||||
expect(logicFunctionExecutorService.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
logicFunctionId: 'resolver-id',
|
||||
workspaceId: 'owner-ws',
|
||||
}),
|
||||
);
|
||||
expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
logicFunctionId: 'target-id',
|
||||
workspaceId: 'target-ws',
|
||||
payload: { from: 'resolver' },
|
||||
}),
|
||||
expect(messageQueueService.add).toHaveBeenCalledWith(
|
||||
LogicFunctionTriggerJob.name,
|
||||
[
|
||||
{
|
||||
logicFunctionId: 'target-id',
|
||||
workspaceId: 'target-ws',
|
||||
payload: { from: 'resolver' },
|
||||
},
|
||||
],
|
||||
{ retryLimit: 3 },
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
statusCode: 200,
|
||||
body: { ok: true },
|
||||
statusCode: 202,
|
||||
body: { queued: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -184,6 +190,21 @@ describe('ServerRouteTriggerService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes the target lookup to the resolver application registration', async () => {
|
||||
await handle();
|
||||
|
||||
expect(logicFunctionRepository.findOne).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
universalIdentifier: TARGET_UID,
|
||||
workspaceId: 'target-ws',
|
||||
application: { applicationRegistrationId: 'reg-1' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws LOGIC_FUNCTION_NOT_FOUND and executes nothing when the query returns no server-route resolver', async () => {
|
||||
resolverRow = null;
|
||||
|
||||
@@ -191,6 +212,7 @@ describe('ServerRouteTriggerService', () => {
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
});
|
||||
expect(logicFunctionExecutorService.execute).not.toHaveBeenCalled();
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects and executes nothing when the resolver requires authentication', async () => {
|
||||
@@ -240,35 +262,16 @@ describe('ServerRouteTriggerService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('throws LOGIC_FUNCTION_NOT_FOUND when the target named by the resolver is missing in the resolved workspace', async () => {
|
||||
it('throws LOGIC_FUNCTION_NOT_FOUND and enqueues nothing when the target is missing', async () => {
|
||||
logicFunctionRepository.findOne.mockReset();
|
||||
logicFunctionRepository.findOne
|
||||
// resolver lookup succeeds
|
||||
.mockResolvedValueOnce({ id: 'resolver-id' })
|
||||
// target lookup returns null
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a target userError as a server-route exception', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
workspaceId: 'target-ws',
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult(null, { errorMessage: 'boom' }),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
|
||||
});
|
||||
expect(messageQueueService.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps a LogicFunctionExecutionException(LOGIC_FUNCTION_NOT_FOUND) to the server-route not-found code', async () => {
|
||||
@@ -285,15 +288,6 @@ describe('ServerRouteTriggerService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to PLATFORM_ERROR for any other thrown executor error', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a LogicFunctionExecutionException(RATE_LIMIT_EXCEEDED) to the server-route rate-limit code', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(
|
||||
@@ -308,33 +302,11 @@ describe('ServerRouteTriggerService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes the target lookup to the resolver application registration', async () => {
|
||||
await handle();
|
||||
|
||||
expect(logicFunctionRepository.findOne).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
universalIdentifier: TARGET_UID,
|
||||
workspaceId: 'target-ws',
|
||||
application: { applicationRegistrationId: 'reg-1' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not leak the raw executor error message to the caller', async () => {
|
||||
it('does not leak the raw resolver executor error message to the caller', async () => {
|
||||
logicFunctionExecutorService.execute.mockReset();
|
||||
logicFunctionExecutorService.execute
|
||||
.mockResolvedValueOnce(
|
||||
buildExecuteResult({
|
||||
workspaceId: 'target-ws',
|
||||
targetLogicFunctionUniversalIdentifier: TARGET_UID,
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new Error('internal: connection to lambda-internal:5000 refused'),
|
||||
);
|
||||
logicFunctionExecutorService.execute.mockRejectedValue(
|
||||
new Error('internal: connection to lambda-internal:5000 refused'),
|
||||
);
|
||||
|
||||
await expect(handle()).rejects.toMatchObject({
|
||||
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR,
|
||||
|
||||
+62
-16
@@ -11,11 +11,15 @@ import {
|
||||
LogicFunctionExecutionExceptionCode,
|
||||
LogicFunctionExecutorService,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
|
||||
import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
|
||||
import {
|
||||
type RouteTriggerResponse,
|
||||
buildRouteTriggerResponse,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
|
||||
LogicFunctionTriggerJob,
|
||||
type LogicFunctionTriggerJobData,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
|
||||
import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
|
||||
import {
|
||||
ServerRouteTriggerException,
|
||||
ServerRouteTriggerExceptionCode,
|
||||
@@ -28,6 +32,8 @@ type ResolverResult = {
|
||||
payload?: object;
|
||||
};
|
||||
|
||||
const QUEUED_TARGET_RETRY_LIMIT = 3;
|
||||
|
||||
@Injectable()
|
||||
export class ServerRouteTriggerService {
|
||||
private readonly logger = new Logger(ServerRouteTriggerService.name);
|
||||
@@ -36,6 +42,8 @@ export class ServerRouteTriggerService {
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
|
||||
async handle({
|
||||
@@ -89,22 +97,13 @@ export class ServerRouteTriggerService {
|
||||
});
|
||||
const resolved = this.parseResolverResult(resolverResult);
|
||||
|
||||
const targetResult = await this.runFunction({
|
||||
return await this.enqueueTargetFunction({
|
||||
logicFunctionUniversalIdentifier:
|
||||
resolved.targetLogicFunctionUniversalIdentifier,
|
||||
workspaceId: resolved.workspaceId,
|
||||
payload: resolved.payload ?? event,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
if (isDefined(targetResult.error)) {
|
||||
throw new ServerRouteTriggerException(
|
||||
targetResult.error.errorMessage,
|
||||
ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return buildRouteTriggerResponse(targetResult.data);
|
||||
}
|
||||
|
||||
private async findResolver({
|
||||
@@ -169,7 +168,7 @@ export class ServerRouteTriggerService {
|
||||
};
|
||||
}
|
||||
|
||||
private async runFunction({
|
||||
private async enqueueTargetFunction({
|
||||
logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
payload,
|
||||
@@ -178,8 +177,38 @@ export class ServerRouteTriggerService {
|
||||
logicFunctionUniversalIdentifier: string;
|
||||
workspaceId: string;
|
||||
payload: object;
|
||||
applicationRegistrationId: string;
|
||||
}): Promise<RouteTriggerResponse> {
|
||||
const logicFunction = await this.findLogicFunctionOrFail({
|
||||
logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
await this.messageQueueService.add<LogicFunctionTriggerJobData[]>(
|
||||
LogicFunctionTriggerJob.name,
|
||||
[
|
||||
{
|
||||
logicFunctionId: logicFunction.id,
|
||||
workspaceId,
|
||||
payload,
|
||||
},
|
||||
],
|
||||
{ retryLimit: QUEUED_TARGET_RETRY_LIMIT },
|
||||
);
|
||||
|
||||
return { statusCode: 202, headers: {}, body: { queued: true } };
|
||||
}
|
||||
|
||||
private async findLogicFunctionOrFail({
|
||||
logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
applicationRegistrationId,
|
||||
}: {
|
||||
logicFunctionUniversalIdentifier: string;
|
||||
workspaceId: string;
|
||||
applicationRegistrationId?: string;
|
||||
}): Promise<{ data: object | null; error?: { errorMessage: string } }> {
|
||||
}): Promise<LogicFunctionEntity> {
|
||||
const logicFunction = await this.logicFunctionRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: logicFunctionUniversalIdentifier,
|
||||
@@ -200,6 +229,23 @@ export class ServerRouteTriggerService {
|
||||
);
|
||||
}
|
||||
|
||||
return logicFunction;
|
||||
}
|
||||
|
||||
private async runFunction({
|
||||
logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
payload,
|
||||
}: {
|
||||
logicFunctionUniversalIdentifier: string;
|
||||
workspaceId: string;
|
||||
payload: object;
|
||||
}): Promise<{ data: object | null; error?: { errorMessage: string } }> {
|
||||
const logicFunction = await this.findLogicFunctionOrFail({
|
||||
logicFunctionUniversalIdentifier,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.logicFunctionExecutorService.execute({
|
||||
logicFunctionId: logicFunction.id,
|
||||
|
||||
Reference in New Issue
Block a user