Skip to content

Commit b504a20

Browse files
icecrasher321claude
andcommitted
improvement(webhooks): cache-first deployed-state reads keyed by deploymentVersionId
loadWorkflowDeploymentVersionState consults the existing 5-min LRU before the SELECT (the id is immutable, and entries now carry their workflowId so a mismatched pair still falls through to the query). blockExistsInDeployment routes through that loader when the webhook row's admitted version id is known, instead of re-reading the entire state jsonb for one boolean — which also warms the in-process cache the inline execution path reads moments later. The raw active-version read remains the null-id fallback, and any failure still answers false. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fca7848 commit b504a20

3 files changed

Lines changed: 117 additions & 10 deletions

File tree

apps/sim/lib/webhooks/processor.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,10 @@ export async function dispatchResolvedWebhookTarget(
870870
}
871871

872872
if (webhookRecord.blockId) {
873-
const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId)
873+
const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId, {
874+
deploymentVersionId: webhookRecord.deploymentVersionId,
875+
workspaceId: foundWorkflow.workspaceId,
876+
})
874877
if (!blockExists) {
875878
const verificationResponse = handlePreDeploymentVerification(webhookRecord, options.requestId)
876879
return {
@@ -956,7 +959,10 @@ export async function processPolledWebhookEvent(
956959
let reservationTransferred = false
957960
try {
958961
if (foundWebhook.blockId) {
959-
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId)
962+
const blockExists = await blockExistsInDeployment(foundWorkflow.id, foundWebhook.blockId, {
963+
deploymentVersionId: foundWebhook.deploymentVersionId,
964+
workspaceId: foundWorkflow.workspaceId,
965+
})
960966
if (!blockExists) {
961967
logger.info(
962968
`[${requestId}] Trigger block ${foundWebhook.blockId} not found in deployment for workflow ${foundWorkflow.id}`

apps/sim/lib/workflows/persistence/utils.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,78 @@ describe('Database Helpers', () => {
13891389
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
13901390
})
13911391

1392+
it('serves a warm admitted version from the cache without the version SELECT', async () => {
1393+
queueTableRows(schemaMock.workflowDeploymentVersion, [
1394+
{ id: 'dv-warm', state: buildDeployedState() },
1395+
])
1396+
1397+
await dbHelpers.loadWorkflowDeploymentVersionState('wf-warm', 'dv-warm', 'workspace-1')
1398+
const second = await dbHelpers.loadWorkflowDeploymentVersionState(
1399+
'wf-warm',
1400+
'dv-warm',
1401+
'workspace-1'
1402+
)
1403+
1404+
expect(second.deploymentVersionId).toBe('dv-warm')
1405+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
1406+
expect(mockSanitizeAgentToolsInBlocks).toHaveBeenCalledTimes(1)
1407+
})
1408+
1409+
it('does not serve a cached version to a different workflow id', async () => {
1410+
queueTableRows(schemaMock.workflowDeploymentVersion, [
1411+
{ id: 'dv-mine', state: buildDeployedState() },
1412+
])
1413+
await dbHelpers.loadWorkflowDeploymentVersionState('wf-mine', 'dv-mine', 'workspace-1')
1414+
1415+
await expect(
1416+
dbHelpers.loadWorkflowDeploymentVersionState('wf-other', 'dv-mine', 'workspace-1')
1417+
).rejects.toThrow('Deployment dv-mine was not found for workflow wf-other')
1418+
})
1419+
1420+
it('blockExistsInDeployment answers from the admitted version and warms the cache', async () => {
1421+
queueTableRows(schemaMock.workflowDeploymentVersion, [
1422+
{ id: 'dv-block', state: buildDeployedState() },
1423+
])
1424+
1425+
await expect(
1426+
dbHelpers.blockExistsInDeployment('wf-block', 'block-1', {
1427+
deploymentVersionId: 'dv-block',
1428+
workspaceId: 'workspace-1',
1429+
})
1430+
).resolves.toBe(true)
1431+
1432+
const deployed = await dbHelpers.loadWorkflowDeploymentVersionState(
1433+
'wf-block',
1434+
'dv-block',
1435+
'workspace-1'
1436+
)
1437+
expect(deployed.blocks['block-1']).toBeDefined()
1438+
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
1439+
1440+
await expect(
1441+
dbHelpers.blockExistsInDeployment('wf-block', 'missing-block', {
1442+
deploymentVersionId: 'dv-block',
1443+
workspaceId: 'workspace-1',
1444+
})
1445+
).resolves.toBe(false)
1446+
})
1447+
1448+
it('blockExistsInDeployment answers false when the admitted version is missing', async () => {
1449+
await expect(
1450+
dbHelpers.blockExistsInDeployment('wf-x', 'block-1', {
1451+
deploymentVersionId: 'dv-missing',
1452+
workspaceId: 'workspace-1',
1453+
})
1454+
).resolves.toBe(false)
1455+
})
1456+
1457+
it('blockExistsInDeployment falls back to the raw active-version read without a version id', async () => {
1458+
queueTableRows(schemaMock.workflowDeploymentVersion, [{ state: buildDeployedState() }])
1459+
1460+
await expect(dbHelpers.blockExistsInDeployment('wf-raw', 'block-1')).resolves.toBe(true)
1461+
expect(mockSanitizeAgentToolsInBlocks).not.toHaveBeenCalled()
1462+
})
1463+
13921464
it('invalidateDeployedStateCache(id) forces a rebuild on the next call', async () => {
13931465
queueActiveVersion('dv-inv', buildDeployedState())
13941466
queueActiveVersion('dv-inv', buildDeployedState())

apps/sim/lib/workflows/persistence/utils.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,29 @@ export interface DeployedWorkflowData extends NormalizedWorkflowData {
9494
variables?: Record<string, unknown>
9595
}
9696

97+
/**
98+
* Answers whether a trigger block exists in the workflow's deployment. When the
99+
* caller already knows the admitted `deploymentVersionId` (webhook rows carry
100+
* it), the check goes through the LRU-backed version loader — warming the cache
101+
* the execution path reads moments later — instead of re-reading the full state
102+
* jsonb for one boolean. Without an id it falls back to the raw active-version
103+
* read. Any failure answers `false`, matching the historical contract.
104+
*/
97105
export async function blockExistsInDeployment(
98106
workflowId: string,
99-
blockId: string
107+
blockId: string,
108+
options?: { deploymentVersionId?: string | null; workspaceId?: string | null }
100109
): Promise<boolean> {
101110
try {
111+
if (options?.deploymentVersionId) {
112+
const deployed = await loadWorkflowDeploymentVersionState(
113+
workflowId,
114+
options.deploymentVersionId,
115+
options.workspaceId ?? undefined
116+
)
117+
return Boolean(deployed.blocks[blockId])
118+
}
119+
102120
const [result] = await db
103121
.select({ state: workflowDeploymentVersion.state })
104122
.from(workflowDeploymentVersion)
@@ -131,10 +149,12 @@ const DEPLOYED_STATE_CACHE_TTL_MS = 5 * 60 * 1000
131149
* absolute on purpose — it bounds the one non-immutable part, the live credential
132150
* remap in `applyBlockMigrations` — so credential changes still propagate.
133151
*/
134-
const deployedStateCache = new LRUCache<string, DeployedWorkflowData>({
135-
max: DEPLOYED_STATE_CACHE_MAX_ENTRIES,
136-
ttl: DEPLOYED_STATE_CACHE_TTL_MS,
137-
})
152+
const deployedStateCache = new LRUCache<string, { workflowId: string; data: DeployedWorkflowData }>(
153+
{
154+
max: DEPLOYED_STATE_CACHE_MAX_ENTRIES,
155+
ttl: DEPLOYED_STATE_CACHE_TTL_MS,
156+
}
157+
)
138158

139159
/** Evicts one deployed-state entry, or clears the cache when no id is given. */
140160
export function invalidateDeployedStateCache(deploymentVersionId?: string): void {
@@ -191,8 +211,8 @@ export async function materializeDeploymentState(
191211
executor?: DbOrTx
192212
): Promise<DeployedWorkflowData> {
193213
const cached = deployedStateCache.get(version.id)
194-
if (cached) {
195-
return structuredClone(cached)
214+
if (cached?.workflowId === workflowId) {
215+
return structuredClone(cached.data)
196216
}
197217

198218
const state = version.state as WorkflowState & { variables?: Record<string, unknown> }
@@ -241,7 +261,7 @@ export async function materializeDeploymentState(
241261
deploymentVersionId: version.id,
242262
}
243263

244-
deployedStateCache.set(version.id, deployedState)
264+
deployedStateCache.set(version.id, { workflowId, data: deployedState })
245265
return structuredClone(deployedState)
246266
}
247267

@@ -283,12 +303,21 @@ export async function loadDeployedWorkflowState(
283303

284304
/**
285305
* Loads an immutable deployment snapshot by ID for work admitted before a later cutover.
306+
*
307+
* Cache-first: the id is immutable, so a warm LRU entry (guarded by matching
308+
* `workflowId`) is byte-identical to what the SELECT + materialization below
309+
* would produce, minus the full-state jsonb round trip.
286310
*/
287311
export async function loadWorkflowDeploymentVersionState(
288312
workflowId: string,
289313
deploymentVersionId: string,
290314
providedWorkspaceId?: string
291315
): Promise<DeployedWorkflowData> {
316+
const cached = deployedStateCache.get(deploymentVersionId)
317+
if (cached?.workflowId === workflowId) {
318+
return structuredClone(cached.data)
319+
}
320+
292321
const [version] = await db
293322
.select({
294323
id: workflowDeploymentVersion.id,

0 commit comments

Comments
 (0)