New unified event system is creating workflow runs from events

This commit is contained in:
Eric Allam
2022-12-27 19:29:45 +00:00
parent ff96cf6597
commit 18fb8ac596
11 changed files with 287 additions and 32 deletions
+21 -6
View File
@@ -5,16 +5,24 @@ import type {
LogMessageSchema,
WaitSchema,
} from "@trigger.dev/common-schemas";
import { ulid } from "ulid";
import type { z } from "zod";
import { prisma } from "~/db.server";
import { IngestEvent } from "~/services/events/ingest.server";
import type { Organization } from "./organization.server";
import type { User } from "./user.server";
import type { Workflow } from "./workflow.server";
type WorkflowRunStatus = WorkflowRun["status"];
export type { WorkflowRun, WorkflowRunStep, WorkflowRunStatus };
export async function findWorklowRunById(id: string) {
return prisma.workflowRun.findUnique({
where: { id },
include: {
event: true,
environment: true,
},
});
}
export async function startWorkflowRun(id: string, apiKey: string) {
const workflowRun = await findWorkflowRunScopedToApiKey(id, apiKey);
@@ -94,9 +102,16 @@ export async function triggerEventInRun(
const ingestService = new IngestEvent();
await ingestService.call(
event,
workflowRun.environment.organization,
workflowRun.environment
{
id: ulid(),
name: event.name,
type: "CUSTOM_EVENT",
service: "trigger",
payload: event.payload,
context: event.context,
apiKey: workflowRun.environment.apiKey,
},
workflowRun.environment.organization
);
}
@@ -0,0 +1,176 @@
import type { EventRule, TriggerEvent } from ".prisma/client";
import { EventFilterSchema } from "@trigger.dev/common-schemas";
import type { PrismaClient } from "~/db.server";
import { prisma } from "~/db.server";
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
import type { Workflow } from "~/models/workflow.server";
import { internalPubSub } from "../messageBroker.server";
export class DispatchEvent {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(id: string) {
const event = await this.#prismaClient.triggerEvent.findUnique({
where: {
id,
},
});
if (!event) {
throw new Error("Event not found");
}
console.log(
`Dispatching event ${event.id}, type = ${event.type}, name = ${event.name}, service = ${event.service}, environment = ${event.environmentId}`
);
const eventRules = await this.#prismaClient.eventRule.findMany({
where: {
organizationId: event.organizationId ?? undefined,
environmentId: event.environmentId ?? undefined,
type: event.type,
},
include: {
workflow: true,
environment: true,
},
});
const matcher = new EventMatcher(event);
const matchingEventRules = eventRules.filter((eventRule) => {
return matcher.matches(eventRule);
});
console.log(
`Found ${matchingEventRules.length} matching event rules for event ${event.id}`
);
const dispatchWorkflowRun = new DispatchWorkflowRun();
await Promise.all(
matchingEventRules.map((eventRule) => {
return dispatchWorkflowRun.call(
eventRule.workflow,
eventRule,
event,
eventRule.environment
);
})
);
await this.#prismaClient.triggerEvent.update({
where: {
id,
},
data: {
dispatchedAt: new Date(),
status: "DISPATCHED",
},
});
}
}
class EventMatcher {
#json: any;
constructor(event: TriggerEvent) {
this.#json = this.#createEventJsonFromEvent(event);
}
public matches(eventRule: EventRule) {
const filter = this.#parseFilter(eventRule);
if (!filter.success) {
return false;
}
return patternMatches(this.#json, filter.data);
}
#parseFilter(eventRule: EventRule) {
const filter = EventFilterSchema.safeParse(eventRule.filter);
return filter;
}
#createEventJsonFromEvent(event: TriggerEvent) {
return {
event: event.name,
service: event.service,
payload: event.payload,
};
}
}
function patternMatches(payload: any, pattern: any): boolean {
for (const [key, value] of Object.entries(pattern)) {
if (Array.isArray(value)) {
if (!value.includes(payload[key])) {
return false;
}
} else if (typeof value === "object") {
if (!patternMatches(payload[key], value)) {
return false;
}
}
}
return true;
}
class DispatchWorkflowRun {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
workflow: Workflow,
eventRule: EventRule,
event: TriggerEvent,
environment: RuntimeEnvironment
) {
if (!workflow) {
throw new Error("Workflow not found");
}
const workflowRun = await this.#prismaClient.workflowRun.create({
data: {
workflow: {
connect: {
id: workflow.id,
},
},
environment: {
connect: {
id: environment.id,
},
},
event: {
connect: {
id: event.id,
},
},
eventRule: {
connect: {
id: eventRule.id,
},
},
status: "PENDING",
},
});
console.log(
`Created workflow run ${workflowRun.id} for event rule ${eventRule.id}`
);
await internalPubSub.publish("WORKFLOW_RUN_CREATED", {
id: workflowRun.id,
});
}
}
@@ -1,3 +1,4 @@
import { JsonSchema } from "@trigger.dev/common-schemas";
import type { CoordinatorCatalog, PlatformCatalog } from "internal-platform";
import {
coordinatorCatalog,
@@ -13,11 +14,13 @@ import { env } from "~/env.server";
import {
completeWorkflowRun,
failWorkflowRun,
findWorklowRunById,
initiateWaitInRun,
logMessageInRun,
startWorkflowRun,
triggerEventInRun,
} from "~/models/workflowRun.server";
import { DispatchEvent } from "./events/dispatch.server";
import { RegisterExternalSource } from "./externalSources/registerExternalSource.server";
let pulsarClient: PulsarClient;
@@ -164,6 +167,10 @@ const InternalCatalog = {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
WORKFLOW_RUN_CREATED: {
data: z.object({ id: z.string() }),
properties: z.object({}),
},
EXTERNAL_SOURCE_UPSERTED: {
data: z.object({ id: z.string() }),
properties: z.object({}),
@@ -191,12 +198,38 @@ async function createInternalPubSub() {
return isRegistered; // Returning true will mean we don't retry
},
EVENT_CREATED: async (id, data, properties) => {
// TODO: this is where we will need to handle the event and find all the event rules that match it
// If the event has an environment associated with it, then query like this:
// SELECT * FROM event_rules WHERE org_id = ${event.orgId} AND environmentId = ${event.environmentId} AND type = ${event.type}
// If the event does not have an environment, then query like this:
// SELECT * FROM event_rules WHERE org_id = ${event.orgId} AND type = ${event.type}
// For each matching event rule, we need to create a new workflow run and publish that to the trigger publisher
const service = new DispatchEvent();
try {
await service.call(data.id);
} catch (error) {
console.log(error);
return false;
}
return true;
},
WORKFLOW_RUN_CREATED: async (id, data, properties) => {
const run = await findWorklowRunById(data.id);
if (!run) {
return true;
}
await triggerPublisher.publish(
"TRIGGER_WORKFLOW",
{
id: run.id,
input: JsonSchema.parse(run.event.payload),
context: JsonSchema.parse(run.event.context),
},
{
"x-api-key": run.environment.apiKey,
"x-org-id": run.environment.organizationId,
"x-workflow-id": run.workflowId,
"x-env": run.environment.slug,
}
);
return true;
},
@@ -81,13 +81,13 @@ export class RegisterWorkflow {
},
},
update: {
rule: payload.trigger.rule,
filter: payload.trigger.filter,
},
create: {
workflowId: workflow.id,
environmentId: environment.id,
organizationId: organization.id,
rule: payload.trigger.rule,
filter: payload.trigger.filter,
type: payload.trigger.type,
trigger: payload.trigger,
},
@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `rule` on the `EventRule` table. All the data in the column will be lost.
- Added the required column `filter` to the `EventRule` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "EventRule" DROP COLUMN "rule",
ADD COLUMN "filter" JSONB NOT NULL;
@@ -0,0 +1,21 @@
/*
Warnings:
- The values [PROCESSED] on the enum `TriggerEventStatus` will be removed. If these variants are still used in the database, this will fail.
- You are about to drop the column `processedAt` on the `TriggerEvent` table. All the data in the column will be lost.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "TriggerEventStatus_new" AS ENUM ('PENDING', 'DISPATCHED');
ALTER TABLE "TriggerEvent" ALTER COLUMN "status" DROP DEFAULT;
ALTER TABLE "TriggerEvent" ALTER COLUMN "status" TYPE "TriggerEventStatus_new" USING ("status"::text::"TriggerEventStatus_new");
ALTER TYPE "TriggerEventStatus" RENAME TO "TriggerEventStatus_old";
ALTER TYPE "TriggerEventStatus_new" RENAME TO "TriggerEventStatus";
DROP TYPE "TriggerEventStatus_old";
ALTER TABLE "TriggerEvent" ALTER COLUMN "status" SET DEFAULT 'PENDING';
COMMIT;
-- AlterTable
ALTER TABLE "TriggerEvent" DROP COLUMN "processedAt",
ADD COLUMN "dispatchedAt" TIMESTAMP(3);
+5 -5
View File
@@ -137,7 +137,7 @@ model EventRule {
environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
environmentId String
rule Json
filter Json
trigger Json
createdAt DateTime @default(now())
@@ -215,14 +215,14 @@ model TriggerEvent {
status TriggerEventStatus @default(PENDING)
WorkflowRun WorkflowRun[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
processedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
dispatchedAt DateTime?
}
enum TriggerEventStatus {
PENDING
PROCESSED
DISPATCHED
}
model WorkflowRun {
+3 -3
View File
@@ -22,8 +22,8 @@ const EventMatcherSchema = z.union([
]);
type EventMatcher = z.infer<typeof EventMatcherSchema>;
export type EventRule = { [key: string]: EventMatcher | EventRule };
export type EventFilter = { [key: string]: EventMatcher | EventFilter };
export const EventRuleSchema: z.ZodType<EventRule> = z.lazy(() =>
z.record(z.union([EventMatcherSchema, EventRuleSchema]))
export const EventFilterSchema: z.ZodType<EventFilter> = z.lazy(() =>
z.record(z.union([EventMatcherSchema, EventFilterSchema]))
);
+5 -5
View File
@@ -1,19 +1,19 @@
import { z } from "zod";
import { EventRuleSchema } from "./events";
import { EventFilterSchema } from "./events";
import { JsonSchema } from "./json";
export const CustomEventTriggerSchema = z.object({
type: z.literal("CUSTOM_EVENT"),
service: z.literal("trigger"),
name: z.string(),
rule: EventRuleSchema,
filter: EventFilterSchema,
});
export const WebhookEventTriggerSchema = z.object({
type: z.literal("WEBHOOK"),
service: z.string(),
name: z.string(),
rule: EventRuleSchema,
filter: EventFilterSchema,
source: JsonSchema,
});
@@ -21,14 +21,14 @@ export const HttpEventTriggerSchema = z.object({
type: z.literal("HTTP_ENDPOINT"),
service: z.literal("trigger"),
name: z.string(),
rule: EventRuleSchema,
filter: EventFilterSchema,
});
export const ScheduledEventTriggerSchema = z.object({
type: z.literal("SCHEDULE"),
service: z.literal("trigger"),
name: z.string(),
rule: EventRuleSchema,
filter: EventFilterSchema,
});
export const TriggerMetadataSchema = z.discriminatedUnion("type", [
@@ -9,7 +9,7 @@ export function repoIssueEvent(params: {
type: "WEBHOOK",
service: "github",
name: "On Issue Event",
rule: {
filter: {
service: ["github"],
payload: {
repository: {
@@ -37,7 +37,7 @@ export function orgIssueEvent(params: {
type: "WEBHOOK",
service: "github",
name: "On Issue Event",
rule: {
filter: {
service: ["github"],
payload: {
organizaton: {
+3 -3
View File
@@ -1,10 +1,10 @@
import {
EventRuleSchema,
EventFilterSchema,
TriggerMetadataSchema,
} from "@trigger.dev/common-schemas";
import { z } from "zod";
export type EventRule = z.infer<typeof EventRuleSchema>;
export type EventRule = z.infer<typeof EventFilterSchema>;
export type TriggerEvent<TSchema extends z.ZodTypeAny> = {
metadata: z.infer<typeof TriggerMetadataSchema>;
@@ -24,7 +24,7 @@ export function customEvent<TSchema extends z.ZodTypeAny>(
type: "CUSTOM_EVENT",
service: "trigger",
name: options.name,
rule: { name: [options.name] },
filter: { event: [options.name] },
},
schema: options.schema,
};