Skip to content
Merged
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
79 changes: 79 additions & 0 deletions apps/sim/lib/posthog/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* @vitest-environment node
*/
import type { MockInstance } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server'

/**
* This is the guarantee that keeps analytics off every critical path: callers
* treat `captureServerEvent` as something that cannot fail, and several — the
* deployment outbox among them — would turn a PostHog outage into failed work
* if it ever started throwing.
*
* The client is built through a lazy `require`, which `vi.mock` cannot
* intercept, so this spies on the real one. Its readiness latches at module
* level, hence stubbing the env before the first read and asserting a client
* exists — without that the whole suite would pass on a disabled no-op.
*/
describe('captureServerEvent', () => {
let captureSpy: MockInstance

beforeAll(() => {
vi.stubEnv('NEXT_PUBLIC_POSTHOG_KEY', 'phc_test')
vi.stubEnv('NEXT_PUBLIC_POSTHOG_ENABLED', 'true')

const client = getPostHogClient()
if (!client) throw new Error('expected an enabled PostHog client to spy on')
captureSpy = vi.spyOn(client, 'capture').mockImplementation(() => {})
})

beforeEach(() => {
captureSpy.mockClear()
captureSpy.mockImplementation(() => {})
})

it('swallows a failing client instead of propagating to the caller', () => {
captureSpy.mockImplementation(() => {
throw new Error('PostHog unreachable')
})

expect(() =>
captureServerEvent('user-1', 'workflow_deployed', {
workflow_id: 'workflow-1',
workspace_id: 'workspace-1',
})
).not.toThrow()
expect(captureSpy).toHaveBeenCalledTimes(1)
})

it('captures synchronously, so a caller cannot await delivery', () => {
const result = captureServerEvent('user-1', 'workflow_deployed', {
workflow_id: 'workflow-1',
workspace_id: 'workspace-1',
})

expect(result).toBeUndefined()
expect(captureSpy).toHaveBeenCalledTimes(1)
})

it('forwards insertId as $insert_id so outbox retries collapse', () => {
captureServerEvent(
'user-1',
'workflow_deployed',
{ workflow_id: 'workflow-1', workspace_id: 'workspace-1' },
{ insertId: 'event-1', groups: { workspace: 'workspace-1' } }
)

expect(captureSpy).toHaveBeenCalledWith(
expect.objectContaining({
distinctId: 'user-1',
event: 'workflow_deployed',
properties: expect.objectContaining({
$insert_id: 'event-1',
$groups: { workspace: 'workspace-1' },
}),
})
)
})
})
19 changes: 0 additions & 19 deletions apps/sim/lib/posthog/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,3 @@ export function captureServerEvent<E extends PostHogEventName>(
logger.warn('Failed to capture PostHog server event', { event, error })
}
}

/** Captures and flushes one outbox event before its durable checkpoint advances. */
export async function deliverOutboxServerEvent<E extends PostHogEventName>(
distinctId: string,
event: E,
properties: PostHogEventMap[E],
options?: CaptureOptions
): Promise<'delivered' | 'skipped'> {
const client = getClient()
if (!client) return 'skipped'

client.capture({
distinctId,
event,
properties: buildCaptureProperties(properties, options),
})
await client.flush()
return 'delivered'
}
89 changes: 81 additions & 8 deletions apps/sim/lib/webhooks/registration-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ const FENCE: WebhookRegistrationOperationFence = {
deploymentVersionId: 'version-3',
}

/** The redeploy that lands seconds after {@link FENCE} and supersedes it. */
const NEXT_FENCE: WebhookRegistrationOperationFence = {
workflowId: 'workflow-1',
operationId: 'operation-2',
generation: 4,
deploymentVersionId: 'version-4',
}

interface UpdateCall {
payload: Record<string, unknown>
condition: Condition
Expand Down Expand Up @@ -125,6 +133,15 @@ function createTx(selectResults: unknown[][]) {
return { tx: tx as unknown as DbOrTx, updates, inserts, updateResults }
}

/** Routes `db.transaction` at a queue-driven tx so store writes are observable. */
function runInTx(selectResults: unknown[][]) {
const harness = createTx(selectResults)
dbChainMockFns.transaction.mockImplementation(
async (callback: (tx: DbOrTx) => Promise<unknown>) => callback(harness.tx)
)
return harness
}

function activeRow(overrides: Record<string, unknown> = {}) {
return {
id: 'wh-active',
Expand Down Expand Up @@ -224,14 +241,6 @@ describe('prepareWebhookRegistrationIntents', () => {
})
})

function runInTx(selectResults: unknown[][]) {
const harness = createTx(selectResults)
dbChainMockFns.transaction.mockImplementation(
async (callback: (tx: DbOrTx) => Promise<unknown>) => callback(harness.tx)
)
return harness
}

const desired = {
blockId: 'block-1',
provider: 'slack',
Expand Down Expand Up @@ -341,3 +350,67 @@ describe('prepareWebhookRegistrationIntents', () => {
expect(updates).toHaveLength(0)
})
})

describe('redeploys racing within seconds', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockClaimWebhookPath.mockResolvedValue('hooks/a')
dbChainMockFns.transaction.mockImplementation(async () => {
throw new Error('db.transaction not configured for this test')
})
})

const desired = {
blockId: 'block-1',
provider: 'slack',
path: 'hooks/a',
routingKey: null,
providerConfig: { url: 'https://example.test' },
configFingerprint: 'fp-new',
}

it('no-ops the superseded attempt and still lands the newer registration', async () => {
mockIsDeploymentOperationCurrent.mockResolvedValue(false)
const superseded = runInTx([[{ id: 'workflow-1' }]])

await expect(
prepareWebhookRegistrationIntents({ fence: FENCE, desired: [desired] })
).rejects.toBeInstanceOf(StaleWebhookRegistrationOperationError)
expect(superseded.inserts).toHaveLength(0)
expect(superseded.updates).toHaveLength(0)
expect(mockClaimWebhookPath).not.toHaveBeenCalled()

mockIsDeploymentOperationCurrent.mockResolvedValue(true)
const winner = runInTx([[{ id: 'workflow-1' }], [], [activeRow()], [], []])

const work = await prepareWebhookRegistrationIntents({ fence: NEXT_FENCE, desired: [desired] })

expect(mockClaimWebhookPath).toHaveBeenCalledWith(expect.anything(), {
path: 'hooks/a',
workflowId: 'workflow-1',
generation: 4,
})
expect(work.candidates).toHaveLength(1)
expect(winner.inserts).toHaveLength(1)
expect(winner.inserts[0].values).toEqual(
expect.objectContaining({
registrationStatus: 'candidate',
registrationGeneration: 4,
deploymentVersionId: 'version-4',
})
)

const activation = createTx([[{ id: 'workflow-1' }], [], []])
await activateWebhookRegistrations(activation.tx, NEXT_FENCE)

expect(activation.updates[1].payload).toEqual(
expect.objectContaining({
registrationStatus: 'active',
deploymentVersionId: 'version-4',
isActive: true,
archivedAt: null,
})
)
})
})
7 changes: 7 additions & 0 deletions apps/sim/lib/workflows/deployment-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ export function parseDeploymentReadiness(value: unknown): DeploymentReadiness |
export const DEPLOYMENT_ERROR_CODES = {
webhookPathConflict: 'webhook_path_conflict',
invalidTriggerConfiguration: 'invalid_trigger_configuration',
/**
* A newer generation took over the workflow while this attempt was running.
* Never a failure — the newer attempt owns the outcome — so it is neither
* persisted on the operation nor counted as non-retryable; it exists to give
* the benign hand-off a greppable identity in logs.
*/
operationSuperseded: 'deployment_operation_superseded',
} as const

const NON_RETRYABLE_DEPLOYMENT_ERROR_CODES = new Set<string>([
Expand Down
Loading
Loading