Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
198 lines
11 KiB
Plaintext
198 lines
11 KiB
Plaintext
---
|
||
title: 백그라운드 작업
|
||
description: 긴 시간이 걸리거나 속도 제한이 걸린 작업은 모두 인라인으로 처리하지 말고, 또 다른 로직 함수 실행을 큐에 넣어 Twenty 워커에 위임하세요.
|
||
icon: layer-group
|
||
---
|
||
|
||
로직 함수 실행은 `timeoutSeconds`(최대 900초)로 제한됩니다. 해당 시간 안에 끝나지 않는 모든 작업(전체 재동기화, 레코드별 팬아웃, 속도 제한이 걸린 서드파티 API 등)은 더 작은 실행 단위로 나눠야 합니다.
|
||
|
||
`enqueueJobs`는 바로 그 일을 합니다. Twenty 워커에게 나중에 앱의 로직 함수 중 하나를 실행해 달라고 요청하며, 각 페이로드당 한 번씩 실행되고 각 실행은 자체 프로세스에서 자체 타임아웃 한도로 처리됩니다. 호출은 즉시 반환됩니다.
|
||
|
||
```text
|
||
┌─────────────────┐ enqueueJobs(...) ┌──────────────┐ ┌────────────────────┐
|
||
│ Logic function │ ─────────────────▶ │ Job queue │──▶│ Logic function │
|
||
│ (returns now) │ │ (workers) │ │ (fresh run/timeout)│
|
||
└─────────────────┘ └──────────────┘ └────────────────────┘
|
||
```
|
||
|
||
## 실행 대기열에 추가하기
|
||
|
||
`twenty-sdk/logic-function`에서 `enqueueJobs`를 가져와 실행할 로직 함수의 `universalIdentifier`를 지정하고, 대기열에 추가할 각 실행마다 하나의 페이로드를 전달하세요.
|
||
|
||
```ts src/logic-functions/sync-all-contacts.ts
|
||
import { enqueueJobs } from 'twenty-sdk/logic-function';
|
||
|
||
await enqueueJobs({
|
||
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
|
||
payloads: [{ page: 1 }],
|
||
});
|
||
```
|
||
|
||
각 실행은 다른 모든 트리거와 정확히 동일하게 핸들러 인수로 해당 페이로드를 받습니다. 대상은 호출자와 **동일한 애플리케이션**에 속해야 합니다. 다른 앱의 함수를 대기열에 추가하려고 하면 `Logic function not found` 오류와 함께 거부되며 아무것도 대기열에 추가되지 않습니다. 한 번의 호출로 최대 `200`개의 페이로드를 허용합니다.
|
||
|
||
<Note>
|
||
`enqueueJobs`는 작업이 실행되었을 때가 아니라 작업이 수락되는 즉시 반환됩니다. 대상들의 결과를 반환하지는 않습니다. 결과를 다시 읽어와야 한다면, 각 대상이 생성한 내용을 [키-값 스토어](/l/ko/developers/extend/apps/logic/key-value-store) 또는 워크스페이스 레코드에 기록하도록 하세요.
|
||
</Note>
|
||
|
||
<Note>
|
||
호출당 하나의 작업을 대기열에 추가하는 이전 `enqueueJob` 도우미는 더 이상 사용되지 않습니다. 대신 요소가 하나인 `payloads` 목록과 함께 `enqueueJobs`를 사용하세요.
|
||
</Note>
|
||
|
||
## 작업 옵션
|
||
|
||
옵션은 배치의 모든 실행에 적용됩니다.
|
||
|
||
| 옵션 | 기본값 | 범위 | 하는 일 |
|
||
| ------------ | --- | ------------------------ | ------------------------------------------------------------------------------- |
|
||
| `retryLimit` | `0` | `0`–`10` | 전체 추가 큐 시도 횟수. 애플리케이션에서 요청한 재시도는 `3`회로 제한됩니다. 두 번 실행해도 안전한 핸들러에 대해서만 이 값을 올리세요. |
|
||
| `delayMs` | `0` | `0`–`604800000` (7 days) | 실행이 가능해지기 전까지 이만큼 대기합니다. |
|
||
|
||
```ts
|
||
await enqueueJobs({
|
||
logicFunctionUniversalIdentifier: '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33',
|
||
payloads: [{ page: 1 }],
|
||
retryLimit: 3,
|
||
delayMs: 60_000,
|
||
});
|
||
```
|
||
|
||
<Note>
|
||
**우선순위는 아직 설정할 수 없습니다.** 대기열에 추가된 작업은 항상 가장 낮은 우선순위로 실행되므로, 플랫폼 작업이 애플리케이션 작업보다 뒤로 밀려 지연되는 일은 없습니다. 우선순위를 제어하는 기능은 곧 제공될 예정입니다.
|
||
</Note>
|
||
|
||
대기열에 추가된 실행은 이를 대기열에 추가한 함수의 실행 사용자를 상속하므로, 동일한 권한으로 동작합니다.
|
||
|
||
## 일시적 실패 재시도하기
|
||
|
||
Twenty는 애플리케이션 코드의 모든 예외를 재시도하지 않습니다. 일반 오류가 throw되면 영구적 실패로 처리됩니다. 일시적 실패의 경우, 큐에 대기 중인 로직 함수는 `RetryableLogicFunctionError`를 throw하여 최대 세 번의 재시도를 요청할 수 있습니다.
|
||
|
||
```ts
|
||
import {
|
||
type LogicFunctionExecutionContext,
|
||
RetryableLogicFunctionError,
|
||
} from 'twenty-sdk/logic-function';
|
||
|
||
export const handler = async (
|
||
_payload: unknown,
|
||
{ retryCount, maxRetries }: LogicFunctionExecutionContext,
|
||
) => {
|
||
const response = await fetch('https://api.example.com/contacts');
|
||
|
||
if (response.status === 429 || response.status >= 500) {
|
||
throw new RetryableLogicFunctionError(
|
||
`The contacts API is temporarily unavailable (${response.status}); retry ${retryCount} of ${maxRetries}`,
|
||
);
|
||
}
|
||
};
|
||
```
|
||
|
||
가능한 경우 `RetryableLogicFunctionError`를 직접 throw하세요. 이를 확장하는 경우 `name`을 바꾸지 마세요. Twenty는 실행 런타임 전반에서 직렬화된 이름 `RetryableLogicFunctionError`를 인식합니다.
|
||
|
||
`retryCount`는 최초 실행 시 `0`이며, 애플리케이션 코드가 재시도를 요청할 때만 증가합니다. `maxRetries`는 최대 `3`이며, 큐에 대기 중인 작업의 전체 재시도 한도가 더 작으면 더 낮을 수 있습니다. 플랫폼 실패는 큐의 전체 안전 예산을 계속 소모하지만 `retryCount`를 증가시키지는 않습니다.
|
||
|
||
큐는 지수 백오프와 지터를 사용하여 재시도 시도를 지연합니다. 정확한 지연 시간은 의도적으로 보장되지 않으므로, 애플리케이션 코드는 정확한 시간에 재시도가 발생하는 것에 의존해서는 안 됩니다. `maxRetries`에 도달하면, 또 다른 `RetryableLogicFunctionError`는 추가 실행 없이 최종 애플리케이션 실패로 기록됩니다.
|
||
|
||
<Warning>
|
||
재시도는 전체 핸들러를 다시 실행하며, 일부 부수 효과가 성공한 후 발생할 수 있습니다. 재시도를 요청하기 전에 핸들러를 멱등적으로 만드세요.
|
||
</Warning>
|
||
|
||
## 사용 예: 긴 동기화를 페이지 단위로 처리하기
|
||
|
||
전형적인 형태는, 다음 커서를 사용해 *자기 자신*을 다시 대기열에 추가하는 함수입니다. 각 실행은 자신의 타임아웃 안에서 한 페이지 분량의 작업만 처리하고, 더 이상 처리할 것이 없으면 이 체인은 멈춥니다.
|
||
|
||
```ts src/logic-functions/sync-contacts-page.ts
|
||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||
import { enqueueJobs } from 'twenty-sdk/logic-function';
|
||
|
||
const SYNC_CONTACTS_PAGE = '9f1c3d7e-51b8-4a29-8f0d-7c4e2a6b1d33';
|
||
|
||
const handler = async (params: { cursor?: string }) => {
|
||
const { contacts, nextCursor } = await fetchContactsPage(params.cursor);
|
||
|
||
await importContacts(contacts);
|
||
|
||
if (nextCursor) {
|
||
await enqueueJobs({
|
||
logicFunctionUniversalIdentifier: SYNC_CONTACTS_PAGE,
|
||
payloads: [{ cursor: nextCursor }],
|
||
delayMs: 2_000,
|
||
});
|
||
}
|
||
|
||
return { imported: contacts.length, done: !nextCursor };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: SYNC_CONTACTS_PAGE,
|
||
name: 'sync-contacts-page',
|
||
timeoutSeconds: 120,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
## 레코드별로 팬아웃하기
|
||
|
||
작업이 본질적으로 항목 단위일 때는, 단일 호출에서 항목마다 하나의 작업을 대기열에 추가하고 워커들이 인라인 루프를 도는 대신 병렬로 처리하도록 하세요.
|
||
|
||
```ts
|
||
const companies = await listCompaniesToEnrich();
|
||
|
||
await enqueueJobs({
|
||
logicFunctionUniversalIdentifier: ENRICH_COMPANY,
|
||
payloads: companies.map((company) => ({ companyId: company.id })),
|
||
retryLimit: 2,
|
||
});
|
||
```
|
||
|
||
## 장시간 실행 작업을 위한 모범 사례
|
||
|
||
대부분의 장시간 작업은 두 가지 규칙으로 다룰 수 있습니다: **루프 대신 재귀를 사용**하고, **실행마다 한정된 크기의 청크를 처리**하세요.
|
||
|
||
한 번에 모든 것을 처리하려는 실행은 실패 패턴입니다. 타임아웃에 걸리고, 재시도가 발생하면 전체 작업을 다시 처음부터 시작합니다. 대신, 하나의 청크가 `timeoutSeconds` 안에 여유 있게 끝나도록 크기를 잡고, 현재 위치를 저장한 뒤 다음 실행을 대기열에 추가하세요.
|
||
|
||
```ts src/logic-functions/enrich-companies-batch.ts
|
||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||
import { enqueueJobs, kv } from 'twenty-sdk/logic-function';
|
||
|
||
const ENRICH_COMPANIES_BATCH = '3f9d1c02-8a44-4f0e-b1d7-9c2e5a7b4f10';
|
||
const CHUNK_SIZE = 50;
|
||
|
||
const handler = async (params: { offset?: number }) => {
|
||
const offset = params.offset ?? 0;
|
||
const companies = await listCompaniesToEnrich({
|
||
offset,
|
||
limit: CHUNK_SIZE,
|
||
});
|
||
|
||
for (const company of companies) {
|
||
await enrichCompany(company);
|
||
}
|
||
|
||
await kv.set('enrich:progress', { offset: offset + companies.length });
|
||
|
||
if (companies.length === CHUNK_SIZE) {
|
||
await enqueueJobs({
|
||
logicFunctionUniversalIdentifier: ENRICH_COMPANIES_BATCH,
|
||
payloads: [{ offset: offset + CHUNK_SIZE }],
|
||
});
|
||
}
|
||
|
||
return { processed: companies.length, done: companies.length < CHUNK_SIZE };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: ENRICH_COMPANIES_BATCH,
|
||
name: 'enrich-companies-batch',
|
||
timeoutSeconds: 300,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
이 접근이 유효한 이유:
|
||
|
||
* **청크 크기는 평균이 아니라 가장 느린 항목을 기준으로 정하세요.** `CHUNK_SIZE × 최악의 항목 처리 시간`은 여유를 두고 `timeoutSeconds` 안에 들어와야 합니다. 그렇지 않으면 실행이 중단될 때 청크의 끝부분이 유실됩니다.
|
||
* **종료 조건을 명시적으로 만드세요.** 가득 찬 청크가 반환되었을 때만 재귀적으로 호출하세요. "결과 없음"만을 기준으로 멈추는 체인은, 소스가 중간에 짧은 페이지를 한 번이라도 반환하면 영원히 계속될 수 있습니다.
|
||
* **다음 실행을 대기열에 추가하기 전에 진행 상태를 저장**해서, 실패한 링크가 처음부터가 아니라 마지막으로 완료된 청크부터 다시 시작하도록 하세요.
|
||
* **각 청크는 멱등성을 유지하세요.** 재시도 후에 하나의 청크를 다시 처리하더라도 중복 기록이 발생하지 않도록, 처리 중인 레코드나 외부 ID를 기준으로 쓰기를 수행해야 합니다.
|
||
* **거대한 한 번의 팬아웃보다 청크 단위 체인을 우선적으로 사용**하세요. 작업이 호출 한도(rate limit)가 있는 서드파티에 도달하는 경우, `delayMs`가 있는 체인은 스스로 속도를 조절하지만, 수천 개의 작업을 한 번에 대기열에 추가하면 모두 즉시 실행 가능 상태가 됩니다.
|