-
Notifications
You must be signed in to change notification settings - Fork 9
feat: data set lifecycle job #588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
silent-cipher
wants to merge
17
commits into
main
Choose a base branch
from
feat/data-set-deletion-job
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
d71b721
docs: add data-set-creation job design documentation
silent-cipher 45a3e90
docs: add data-set-deletion job design documentation
silent-cipher e654660
docs: simplify terminated slot skip
silent-cipher c08d54f
Merge branch 'main' into feat/data-set-deletion-job
silent-cipher 6af976b
docs: rename + more explanations
silent-cipher c537509
chore: address pr comments
silent-cipher fac137f
feat: add data_set_termination canary job
silent-cipher 91ffcdc
doc: udpate docs
silent-cipher 484996e
refactor: pivot to data_set_lifecycle_check
silent-cipher 701b463
chore: format
silent-cipher 7024e33
refactor: consolidate logging context + early checks
silent-cipher 4ee5714
fix: default job timeout to 6 mins
silent-cipher 11b8701
docs: update data set lifecycle check doc
silent-cipher f2fa4ce
chore: revert back deal service
silent-cipher 81b049d
refactor: create separate data set lifecycle service
silent-cipher 980e668
docs: update docs
silent-cipher 524cdc5
docs: update default value
silent-cipher File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
11 changes: 11 additions & 0 deletions
11
apps/backend/src/data-set-lifecycle/data-set-lifecycle.module.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { Module } from "@nestjs/common"; | ||
| import { MetricsPrometheusModule } from "../metrics-prometheus/metrics-prometheus.module.js"; | ||
| import { WalletSdkModule } from "../wallet-sdk/wallet-sdk.module.js"; | ||
| import { DataSetLifecycleService } from "./data-set-lifecycle.service.js"; | ||
|
|
||
| @Module({ | ||
| imports: [WalletSdkModule, MetricsPrometheusModule], | ||
| providers: [DataSetLifecycleService], | ||
| exports: [DataSetLifecycleService], | ||
| }) | ||
| export class DataSetLifecycleModule {} |
131 changes: 131 additions & 0 deletions
131
apps/backend/src/data-set-lifecycle/data-set-lifecycle.service.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| import { beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { DataSetLifecycleCheckMetrics } from "../metrics-prometheus/check-metrics.service.js"; | ||
| import { WalletSdkService } from "../wallet-sdk/wallet-sdk.service.js"; | ||
| import { DataSetLifecycleService } from "./data-set-lifecycle.service.js"; | ||
|
|
||
| vi.mock("@filoz/synapse-core/sp", () => ({ | ||
| createDataSet: vi.fn(), | ||
| waitForCreateDataSet: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@filoz/synapse-core/warm-storage", () => ({ | ||
| terminateServiceSync: vi.fn(), | ||
| })); | ||
|
|
||
| const { createDataSet, waitForCreateDataSet } = await import("@filoz/synapse-core/sp"); | ||
| const { terminateServiceSync } = await import("@filoz/synapse-core/warm-storage"); | ||
|
|
||
| const mockClient = { account: { address: "0xwallet" } }; | ||
|
|
||
| const mockProviderInfo = { | ||
| id: 1n, | ||
| name: "test-sp", | ||
| isApproved: true, | ||
| serviceProvider: "0xsp" as `0x${string}`, | ||
| payee: "0xpayee" as `0x${string}`, | ||
| pdp: { serviceURL: "https://sp.example.com" }, | ||
| }; | ||
|
|
||
| const mockWalletSdkService = { | ||
| getProviderInfo: vi.fn(() => mockProviderInfo), | ||
| getSynapseClient: vi.fn(() => mockClient), | ||
| } as unknown as WalletSdkService; | ||
|
|
||
| const mockMetrics = { | ||
| observeCheckDuration: vi.fn(), | ||
| recordStatus: vi.fn(), | ||
| } as unknown as DataSetLifecycleCheckMetrics; | ||
|
|
||
| describe("DataSetLifecycleService", () => { | ||
| let service: DataSetLifecycleService; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| service = new DataSetLifecycleService(mockWalletSdkService, mockMetrics); | ||
| }); | ||
|
|
||
| it("creates an empty data set, waits for confirmation, terminates it, and records success", async () => { | ||
| vi.mocked(createDataSet).mockResolvedValue({ txHash: "0xhash1", statusUrl: "https://sp.example.com/status/1" }); | ||
| vi.mocked(waitForCreateDataSet).mockResolvedValue({ | ||
| dataSetId: 42n, | ||
| dataSetCreated: true, | ||
| txStatus: "confirmed", | ||
| ok: true, | ||
| createMessageHash: "0xmsg", | ||
| service: "https://sp.example.com", | ||
| }); | ||
| vi.mocked(terminateServiceSync).mockResolvedValue({ receipt: {} as any, event: {} as any }); | ||
|
|
||
| await service.runLifecycleCheck("0xsp", { dealbotLifecycleCheck: "nonce-123" }); | ||
|
|
||
| expect(createDataSet).toHaveBeenCalledWith( | ||
| mockClient, | ||
| expect.objectContaining({ | ||
| cdn: false, | ||
| payee: "0xpayee", | ||
| serviceURL: "https://sp.example.com", | ||
| metadata: { dealbotLifecycleCheck: "nonce-123" }, | ||
| }), | ||
| ); | ||
| expect(waitForCreateDataSet).toHaveBeenCalledWith( | ||
| expect.objectContaining({ statusUrl: "https://sp.example.com/status/1" }), | ||
| ); | ||
| expect(terminateServiceSync).toHaveBeenCalledWith(mockClient, expect.objectContaining({ dataSetId: 42n })); | ||
| expect(mockMetrics.observeCheckDuration).toHaveBeenCalledOnce(); | ||
| expect(mockMetrics.recordStatus).toHaveBeenCalledWith(expect.any(Object), "success"); | ||
| }); | ||
|
|
||
| it("records failure.timedout when signal is aborted before creation", async () => { | ||
| const controller = new AbortController(); | ||
| controller.abort(new Error("job timeout")); | ||
|
|
||
| await expect( | ||
| service.runLifecycleCheck("0xsp", { dealbotLifecycleCheck: "nonce-456" }, controller.signal), | ||
| ).rejects.toThrow(); | ||
|
|
||
| expect(createDataSet).not.toHaveBeenCalled(); | ||
| expect(mockMetrics.recordStatus).toHaveBeenCalledWith(expect.any(Object), "failure.timedout"); | ||
| }); | ||
|
|
||
| it("records failure.other when creation rejects with a non-abort error", async () => { | ||
| vi.mocked(createDataSet).mockRejectedValue(new Error("SP unreachable")); | ||
|
|
||
| await expect(service.runLifecycleCheck("0xsp", { dealbotLifecycleCheck: "nonce-789" })).rejects.toThrow( | ||
| "SP unreachable", | ||
| ); | ||
|
|
||
| expect(terminateServiceSync).not.toHaveBeenCalled(); | ||
| expect(mockMetrics.recordStatus).toHaveBeenCalledWith(expect.any(Object), "failure.other"); | ||
| }); | ||
|
|
||
| it("records failure.other when termination fails after creation, logging the dataSetId as leaked", async () => { | ||
| vi.mocked(createDataSet).mockResolvedValue({ txHash: "0xhash2", statusUrl: "https://sp.example.com/status/2" }); | ||
| vi.mocked(waitForCreateDataSet).mockResolvedValue({ | ||
| dataSetId: 99n, | ||
| dataSetCreated: true, | ||
| txStatus: "confirmed", | ||
| ok: true, | ||
| createMessageHash: "0xmsg2", | ||
| service: "https://sp.example.com", | ||
| }); | ||
| vi.mocked(terminateServiceSync).mockRejectedValue(new Error("terminate failed")); | ||
|
|
||
| await expect(service.runLifecycleCheck("0xsp", { dealbotLifecycleCheck: "nonce-999" })).rejects.toThrow( | ||
| "terminate failed", | ||
| ); | ||
|
|
||
| expect(mockMetrics.recordStatus).toHaveBeenCalledWith(expect.any(Object), "failure.other"); | ||
| }); | ||
|
|
||
| it("throws when provider is not found in registry", async () => { | ||
| vi.mocked(mockWalletSdkService.getProviderInfo).mockReturnValueOnce(undefined); | ||
|
|
||
| await expect(service.runLifecycleCheck("0xunknown", {})).rejects.toThrow("not found in registry"); | ||
| }); | ||
|
|
||
| it("throws when synapse client is not initialized", async () => { | ||
| vi.mocked(mockWalletSdkService.getSynapseClient).mockReturnValueOnce(null); | ||
|
|
||
| await expect(service.runLifecycleCheck("0xsp", {})).rejects.toThrow("not initialized"); | ||
| }); | ||
| }); |
149 changes: 149 additions & 0 deletions
149
apps/backend/src/data-set-lifecycle/data-set-lifecycle.service.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { createDataSet, waitForCreateDataSet } from "@filoz/synapse-core/sp"; | ||
| import { terminateServiceSync } from "@filoz/synapse-core/warm-storage"; | ||
| import { Injectable, Logger } from "@nestjs/common"; | ||
| import { awaitWithAbort } from "../common/abort-utils.js"; | ||
| import { toStructuredError } from "../common/logging.js"; | ||
| import { buildCheckMetricLabels, classifyFailureStatus } from "../metrics-prometheus/check-metric-labels.js"; | ||
| import { DataSetLifecycleCheckMetrics } from "../metrics-prometheus/check-metrics.service.js"; | ||
| import { WalletSdkService } from "../wallet-sdk/wallet-sdk.service.js"; | ||
|
|
||
| @Injectable() | ||
| export class DataSetLifecycleService { | ||
| private readonly logger = new Logger(DataSetLifecycleService.name); | ||
|
|
||
| constructor( | ||
| private readonly walletSdkService: WalletSdkService, | ||
| private readonly lifecycleCheckMetrics: DataSetLifecycleCheckMetrics, | ||
| ) {} | ||
|
|
||
| /** | ||
| * Run one data-set lifecycle check: create an empty throwaway data set on the SP, | ||
| * wait for on-chain confirmation, then immediately terminate it. Used by the | ||
| * `data_set_lifecycle_check` canary job to validate that an SP honours the full | ||
| * create → terminate lifecycle. | ||
| * | ||
| * Never touches managed check data sets and creates no Deal rows. The throwaway set | ||
| * is identified by the fixed `dealbotLifecycleCheck` marker key in `metadata`; a | ||
| * per-run nonce value prevents accidentally reusing a prior leaked set. If creation | ||
| * succeeds but termination fails the set leaks (accepted trade-off); operators can | ||
| * sweep leaks by that key. | ||
| * | ||
| * Emits only `dataSetLifecycleCheckStatus` / `dataSetLifecycleCheckMs` metrics. | ||
| */ | ||
| async runLifecycleCheck(spAddress: string, metadata: Record<string, string>, signal?: AbortSignal): Promise<void> { | ||
| const providerInfo = this.walletSdkService.getProviderInfo(spAddress); | ||
| if (!providerInfo) { | ||
| throw new Error(`Provider ${spAddress} not found in registry`); | ||
| } | ||
|
|
||
| const client = this.walletSdkService.getSynapseClient(); | ||
| if (!client) { | ||
| throw new Error("Synapse client not initialized"); | ||
| } | ||
|
|
||
| const labels = buildCheckMetricLabels({ | ||
| checkType: "dataSetLifecycleCheck", | ||
| providerId: providerInfo.id, | ||
| providerName: providerInfo.name, | ||
| providerIsApproved: providerInfo.isApproved, | ||
| }); | ||
|
|
||
| const logContext = { | ||
| providerAddress: spAddress, | ||
| providerName: providerInfo.name, | ||
| providerId: providerInfo.id, | ||
| }; | ||
|
|
||
| const startedAt = Date.now(); | ||
| this.logger.log({ | ||
| event: "dataset_lifecycle_check_started", | ||
| message: "Starting data-set lifecycle check", | ||
| ...logContext, | ||
| }); | ||
|
|
||
| let dataSetId: bigint | undefined; | ||
| try { | ||
| signal?.throwIfAborted(); | ||
|
|
||
| // 1. Request creation of an empty data set on the SP. | ||
| const createResult = await awaitWithAbort( | ||
| createDataSet(client, { | ||
| cdn: false, | ||
| payee: providerInfo.payee, | ||
| serviceURL: providerInfo.pdp.serviceURL, | ||
| metadata, | ||
| }), | ||
| signal, | ||
| ); | ||
| signal?.throwIfAborted(); | ||
|
|
||
| this.logger.log({ | ||
| event: "dataset_lifecycle_check_creating", | ||
| message: "Empty data set creation submitted; waiting for SP confirmation", | ||
| ...logContext, | ||
| txHash: createResult.txHash, | ||
| }); | ||
|
|
||
| // 2. Wait for the SP to confirm the data set is created and extract the dataSetId. | ||
| const confirmed = await awaitWithAbort(waitForCreateDataSet({ statusUrl: createResult.statusUrl }), signal); | ||
| dataSetId = confirmed.dataSetId; | ||
| signal?.throwIfAborted(); | ||
|
|
||
| this.logger.log({ | ||
| event: "dataset_lifecycle_check_created", | ||
| message: "Empty data set created and confirmed on-chain", | ||
| ...logContext, | ||
| dataSetId: dataSetId.toString(), | ||
| }); | ||
|
|
||
| // 3. Immediately terminate the throwaway data set. | ||
| await awaitWithAbort( | ||
| terminateServiceSync(client, { | ||
| dataSetId, | ||
| onHash: (hash) => { | ||
| this.logger.log({ | ||
| event: "dataset_lifecycle_check_terminating", | ||
| message: "Terminate transaction submitted", | ||
| ...logContext, | ||
| dataSetId: (dataSetId as bigint).toString(), | ||
| txHash: hash, | ||
| }); | ||
| }, | ||
| }), | ||
| signal, | ||
| ); | ||
|
|
||
| const durationMs = Date.now() - startedAt; | ||
| this.lifecycleCheckMetrics.observeCheckDuration(labels, durationMs); | ||
| this.lifecycleCheckMetrics.recordStatus(labels, "success"); | ||
|
|
||
| this.logger.log({ | ||
| event: "dataset_lifecycle_check_succeeded", | ||
| message: "Data-set lifecycle check completed: created and terminated throwaway data set", | ||
| ...logContext, | ||
| dataSetId: dataSetId.toString(), | ||
| durationMs, | ||
| }); | ||
| } catch (error) { | ||
| const durationMs = Date.now() - startedAt; | ||
| const status = signal?.aborted ? "failure.timedout" : classifyFailureStatus(error); | ||
| if (status === "failure.timedout") { | ||
| this.lifecycleCheckMetrics.observeCheckDuration(labels, durationMs); | ||
| } | ||
| this.lifecycleCheckMetrics.recordStatus(labels, status); | ||
| this.logger.error({ | ||
| event: "dataset_lifecycle_check_failed", | ||
| message: | ||
| dataSetId === undefined | ||
| ? "Data-set lifecycle check failed during creation" | ||
| : "Data-set lifecycle check failed during termination; throwaway data set may have leaked", | ||
| ...logContext, | ||
| dataSetId: dataSetId?.toString(), | ||
| durationMs, | ||
| status, | ||
| error: toStructuredError(error), | ||
| }); | ||
| throw error; | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.