add backend hatchet worker for calendar predue sync

This commit is contained in:
Ruslan Bakiev
2026-03-08 19:15:30 +07:00
parent 0df426d5d6
commit e4870ce669
21 changed files with 1859 additions and 350 deletions

13
backend_worker/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY tsconfig.json ./tsconfig.json
ENV NODE_ENV=production
CMD ["npm", "run", "start"]

24
backend_worker/README.md Normal file
View File

@@ -0,0 +1,24 @@
# backend_worker
Hatchet worker для периодических backend-задач.
## Назначение
- запускает cron workflow `backend-calendar-timeline-scheduler`;
- вызывает `backend` GraphQL mutation `syncCalendarPredueTimeline`;
- заменяет legacy `schedulers/` сервис для предзаписи календарных событий в `ClientTimelineEntry`.
## Переменные окружения
- `BACKEND_GRAPHQL_URL` (required)
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `BACKEND_TIMELINE_SYNC_CRON` (default: `* * * * *`)
- `HATCHET_CLIENT_TOKEN` (required)
- `HATCHET_CLIENT_TLS_STRATEGY` (optional, например `none` для self-host без TLS)
- `HATCHET_CLIENT_HOST_PORT` (optional, например `hatchet-engine:7070`)
- `HATCHET_CLIENT_API_URL` (optional)
## Скрипты
- `npm run start` — запуск Hatchet worker.
- `npm run typecheck` — проверка TypeScript.

1456
backend_worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"name": "crm-backend-worker",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/hatchet/worker.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hatchet-dev/typescript-sdk": "^1.15.2"
},
"devDependencies": {
"@types/node": "^22.13.9",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,3 @@
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1";
export const hatchet = HatchetClient.init();

View File

@@ -0,0 +1,22 @@
import { hatchet } from "./client";
import { backendCalendarTimelineScheduler } from "./workflow";
import path from "node:path";
import { fileURLToPath } from "node:url";
async function main() {
const worker = await hatchet.worker("backend-worker", {
workflows: [backendCalendarTimelineScheduler],
});
await worker.start();
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().catch((error) => {
const message = error instanceof Error ? error.stack || error.message : String(error);
console.error(`[backend_worker/hatchet] worker failed: ${message}`);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,119 @@
import { hatchet } from "./client";
type SyncCalendarPredueResult = {
syncCalendarPredueTimeline: {
ok: boolean;
message: string;
now: string;
scanned: number;
updated: number;
skippedBeforeWindow: number;
skippedLocked: boolean;
preDueMinutes: number;
lookbackMinutes: number;
lookaheadMinutes: number;
lockKey: number;
};
};
type GraphqlResponse<T> = {
data?: T;
errors?: Array<{ message?: string }>;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function requiredEnv(name: string) {
const value = asString(process.env[name]);
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
async function callBackendSyncMutation() {
const url = requiredEnv("BACKEND_GRAPHQL_URL");
const secret = asString(process.env.BACKEND_GRAPHQL_SHARED_SECRET);
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (secret) {
headers["x-graphql-secret"] = secret;
}
const query = `mutation SyncCalendarPredueTimeline {
syncCalendarPredueTimeline {
ok
message
now
scanned
updated
skippedBeforeWindow
skippedLocked
preDueMinutes
lookbackMinutes
lookaheadMinutes
lockKey
}
}`;
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
operationName: "SyncCalendarPredueTimeline",
query,
variables: {},
}),
});
const payload = (await response.json()) as GraphqlResponse<SyncCalendarPredueResult>;
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.map((error) => error.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(message);
}
const result = payload.data?.syncCalendarPredueTimeline;
if (!result?.ok) {
throw new Error(result?.message || "syncCalendarPredueTimeline failed");
}
return result;
}
const BACKEND_WORKER_CRON = asString(process.env.BACKEND_TIMELINE_SYNC_CRON) || "* * * * *";
export const backendCalendarTimelineScheduler = hatchet.workflow({
name: "backend-calendar-timeline-scheduler",
on: {
cron: BACKEND_WORKER_CRON,
},
});
backendCalendarTimelineScheduler.task({
name: "sync-calendar-predue-timeline-in-backend",
retries: 6,
backoff: {
factor: 2,
maxSeconds: 60,
},
fn: async (_, ctx) => {
const result = await callBackendSyncMutation();
await ctx.logger.info("backend timeline predue sync completed", {
scanned: result.scanned,
updated: result.updated,
skippedBeforeWindow: result.skippedBeforeWindow,
skippedLocked: result.skippedLocked,
now: result.now,
});
return result;
},
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"],
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}