@useairfoil/connector-kit 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/METRICS.md +78 -0
- package/README.md +190 -21
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth-DvU_CZ7C.js +42 -0
- package/dist/auth-DvU_CZ7C.js.map +1 -0
- package/dist/connector-app/index.d.ts +14 -9
- package/dist/connector-app/index.js +9 -6
- package/dist/connector-app-d7Z-SZLQ.js +64 -0
- package/dist/connector-app-d7Z-SZLQ.js.map +1 -0
- package/dist/constants-BRhIX_rz.js +27 -0
- package/dist/constants-BRhIX_rz.js.map +1 -0
- package/dist/errors/index.d.ts +1 -1
- package/dist/{index-DK9YcR6W.d.ts → index-Byr0Lf0C.d.ts} +4 -4
- package/dist/index-CBT-JX-T.d.ts +173 -0
- package/dist/index-CTHVudvQ.d.ts +41 -0
- package/dist/index-CTLYQ74P.d.ts +80 -0
- package/dist/index-Coh_SkBs.d.ts +95 -0
- package/dist/{index-DyuT2jzP.d.ts → index-D54ySAKn.d.ts} +1 -1
- package/dist/{index-BlhJCpEk.d.ts → index-DtJX_tic.d.ts} +39 -10
- package/dist/{index-8KnVpoZp.d.ts → index-J42AZaXU.d.ts} +11 -5
- package/dist/{index-CQJsFJs3.d.ts → index-fIIBVAai.d.ts} +6 -4
- package/dist/index-o1-Z3n7V.d.ts +55 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.js +14 -8
- package/dist/index.js.map +1 -1
- package/dist/ingestion/index.d.ts +5 -5
- package/dist/ingestion/index.js +7 -4
- package/dist/ingestion-BQo4cbGw.js +200 -0
- package/dist/ingestion-BQo4cbGw.js.map +1 -0
- package/dist/manifest/index.d.ts +2 -0
- package/dist/manifest/index.js +4 -0
- package/dist/manifest-ByE6YPG0.js +221 -0
- package/dist/manifest-ByE6YPG0.js.map +1 -0
- package/dist/metrics-CVAk1ALR.d.ts +53 -0
- package/dist/metrics-CWPy2Aqc.js +96 -0
- package/dist/metrics-CWPy2Aqc.js.map +1 -0
- package/dist/metrics.d.ts +4 -0
- package/dist/metrics.js +3 -0
- package/dist/publisher/index.d.ts +4 -4
- package/dist/publisher/index.js +3 -2
- package/dist/{publisher-DusgYvaV.js → publisher-BYT3RLVO.js} +2 -2
- package/dist/{publisher-DusgYvaV.js.map → publisher-BYT3RLVO.js.map} +1 -1
- package/dist/runtime-config/index.d.ts +2 -0
- package/dist/runtime-config/index.js +4 -0
- package/dist/runtime-config-BpB-d_bF.js +51 -0
- package/dist/runtime-config-BpB-d_bF.js.map +1 -0
- package/dist/server-CXTQ7odl.js +183 -0
- package/dist/server-CXTQ7odl.js.map +1 -0
- package/dist/{telemetry-p0dE5Mdu.js → service-DuFiQwid.js} +30 -10
- package/dist/service-DuFiQwid.js.map +1 -0
- package/dist/{service-B7g_lHMI.d.ts → service-IE5qaf9x.d.ts} +3 -3
- package/dist/state-store/index.d.ts +3 -4
- package/dist/state-store/index.js +4 -2
- package/dist/state-store-yl6LiTQ0.js +198 -0
- package/dist/state-store-yl6LiTQ0.js.map +1 -0
- package/dist/status/index.d.ts +5 -0
- package/dist/status/index.js +6 -0
- package/dist/status-B-oZBc33.js +84 -0
- package/dist/status-B-oZBc33.js.map +1 -0
- package/dist/{types-ZNrA-Dn0.d.ts → types-B35J5YPu.d.ts} +20 -6
- package/dist/webhook/index.d.ts +5 -4
- package/dist/webhook/index.js +5 -3
- package/dist/{webhook-BSA-tXtU.js → webhook-DI-Afxkg.js} +2 -2
- package/dist/{webhook-BSA-tXtU.js.map → webhook-DI-Afxkg.js.map} +1 -1
- package/package.json +36 -7
- package/src/auth/index.ts +1 -0
- package/src/auth/token-cache.ts +54 -0
- package/src/connector-app/check.ts +76 -0
- package/src/connector-app/index.ts +1 -0
- package/src/connector-app/start.ts +4 -0
- package/src/core/builder.ts +8 -3
- package/src/core/types.ts +20 -2
- package/src/index.ts +5 -0
- package/src/ingestion/engine.ts +246 -133
- package/src/manifest/config.ts +232 -0
- package/src/manifest/index.ts +14 -0
- package/src/manifest/manifest.ts +31 -0
- package/src/manifest/number-schema.ts +17 -0
- package/src/manifest/schema.ts +160 -0
- package/src/metrics.ts +128 -0
- package/src/publisher/instrumented.ts +68 -0
- package/src/runtime-config/constants.ts +27 -0
- package/src/runtime-config/error.ts +13 -0
- package/src/runtime-config/index.ts +3 -0
- package/src/runtime-config/layer.ts +56 -0
- package/src/state-store/index.ts +2 -1
- package/src/state-store/keys.ts +22 -0
- package/src/state-store/layer-key-value-store.ts +132 -0
- package/src/state-store/layer-memory.ts +11 -0
- package/src/state-store/layer-sql.ts +50 -0
- package/src/state-store/schema.ts +57 -0
- package/src/state-store/service.ts +39 -6
- package/src/state-store/state.ts +17 -0
- package/src/status/index.ts +2 -0
- package/src/status/route.ts +77 -0
- package/src/status/types.ts +33 -0
- package/src/telemetry.ts +46 -4
- package/src/webhook/server.ts +54 -19
- package/dist/connector-app-CvqbfN6R.js +0 -32
- package/dist/connector-app-CvqbfN6R.js.map +0 -1
- package/dist/index-DaNW63Mv.d.ts +0 -21
- package/dist/ingestion-CMCroK7Y.js +0 -171
- package/dist/ingestion-CMCroK7Y.js.map +0 -1
- package/dist/server-DAeFWT4o.js +0 -113
- package/dist/server-DAeFWT4o.js.map +0 -1
- package/dist/state-store-DG_4QpWG.js +0 -31
- package/dist/state-store-DG_4QpWG.js.map +0 -1
- package/dist/telemetry-p0dE5Mdu.js.map +0 -1
- package/src/state-store/memory.ts +0 -25
package/src/ingestion/engine.ts
CHANGED
|
@@ -1,70 +1,36 @@
|
|
|
1
|
-
import { DateTime, Effect, Layer,
|
|
1
|
+
import { DateTime, Effect, Layer, Queue } from "effect";
|
|
2
2
|
import { HttpRouter, type HttpServer, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import * as Observability from "effect/unstable/observability";
|
|
3
4
|
|
|
4
5
|
import type {
|
|
5
6
|
ConnectorDefinition,
|
|
6
7
|
Cursor,
|
|
7
|
-
ResourceBatch,
|
|
8
8
|
ResourceDefinition,
|
|
9
9
|
ResourceState,
|
|
10
10
|
WebhookRoute,
|
|
11
11
|
} from "../core/types";
|
|
12
|
+
import type { StateOperation, StateSource } from "../state-store/schema";
|
|
12
13
|
|
|
13
14
|
import { ConnectorError } from "../errors";
|
|
15
|
+
import * as Metrics from "../metrics";
|
|
16
|
+
import { publishBatch } from "../publisher/instrumented";
|
|
14
17
|
import { Publisher } from "../publisher/service";
|
|
15
18
|
import { StateStore } from "../state-store";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
EventName,
|
|
20
|
-
SpanName,
|
|
21
|
-
addCurrentSpanEvent,
|
|
22
|
-
annotateError,
|
|
23
|
-
} from "../telemetry";
|
|
19
|
+
import { deriveSyncState, normalizeCursor } from "../state-store/state";
|
|
20
|
+
import { statusRoute } from "../status";
|
|
21
|
+
import { Attr, SpanName, annotateError } from "../telemetry";
|
|
24
22
|
import { router, WebhookQueue, type QueuedWebhookBatch } from "../webhook/server";
|
|
25
23
|
|
|
26
|
-
const connectorBatchesTotal = Metric.counter("connector_batches_total", {
|
|
27
|
-
description: "Total resource batches attempted by connector sources",
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const connectorMutationsTotal = Metric.counter("connector_mutations_total", {
|
|
31
|
-
description: "Total resource mutations attempted by connector sources",
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
const connectorBatchSize = Metric.histogram("connector_batch_size", {
|
|
35
|
-
description: "Distribution of resource mutation batch sizes",
|
|
36
|
-
boundaries: [1, 5, 10, 25, 50, 100, 250, 500, 1000],
|
|
37
|
-
});
|
|
38
|
-
|
|
39
24
|
type RunBaseOptions = {
|
|
40
25
|
readonly initialCutoff?: Cursor.Value;
|
|
41
26
|
};
|
|
42
27
|
|
|
43
|
-
const normalizeCursor = (value: Cursor.Value): Cursor.Value =>
|
|
44
|
-
value instanceof Date ? value.toISOString() : value;
|
|
45
|
-
|
|
46
|
-
const normalizeResourceState = (state: ResourceState): ResourceState => ({
|
|
47
|
-
backfill: state.backfill
|
|
48
|
-
? {
|
|
49
|
-
cutoff: normalizeCursor(state.backfill.cutoff),
|
|
50
|
-
pageCursor:
|
|
51
|
-
state.backfill.pageCursor === undefined
|
|
52
|
-
? undefined
|
|
53
|
-
: normalizeCursor(state.backfill.pageCursor),
|
|
54
|
-
completed: state.backfill.completed,
|
|
55
|
-
}
|
|
56
|
-
: undefined,
|
|
57
|
-
changes: state.changes
|
|
58
|
-
? {
|
|
59
|
-
cursor: normalizeCursor(state.changes.cursor),
|
|
60
|
-
}
|
|
61
|
-
: undefined,
|
|
62
|
-
});
|
|
63
|
-
|
|
64
28
|
export type RunOptions = RunBaseOptions & {
|
|
65
29
|
readonly webhook?: {
|
|
66
30
|
readonly routes: ReadonlyArray<WebhookRoute>;
|
|
67
31
|
readonly healthPath?: HttpRouter.PathInput;
|
|
32
|
+
readonly metricsPath?: HttpRouter.PathInput | false;
|
|
33
|
+
readonly statusPath?: HttpRouter.PathInput | false;
|
|
68
34
|
readonly disableHttpLogger?: boolean;
|
|
69
35
|
};
|
|
70
36
|
};
|
|
@@ -77,6 +43,10 @@ type RunWebhookOptions = RunOptions & {
|
|
|
77
43
|
readonly webhook: NonNullable<RunOptions["webhook"]>;
|
|
78
44
|
};
|
|
79
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Runs all connector ingestion loops. Callers provide the Publisher and
|
|
48
|
+
* StateStore layers; webhook mode additionally requires an HttpServer layer.
|
|
49
|
+
*/
|
|
80
50
|
export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
81
51
|
connector: ConnectorDefinition<Resources>,
|
|
82
52
|
options?: RunNoWebhookOptions,
|
|
@@ -86,8 +56,12 @@ export function run<const Resources extends ReadonlyArray<ResourceDefinition>>(
|
|
|
86
56
|
options: RunWebhookOptions,
|
|
87
57
|
): Effect.Effect<void, ConnectorError, StateStore | Publisher | HttpServer.HttpServer>;
|
|
88
58
|
export function run(connector: ConnectorDefinition, options?: RunOptions) {
|
|
59
|
+
const queueLayer = makeWebhookQueueLayer(connector.name);
|
|
89
60
|
const runtimeLayer = options?.webhook
|
|
90
|
-
? Layer.mergeAll(
|
|
61
|
+
? Layer.mergeAll(
|
|
62
|
+
queueLayer,
|
|
63
|
+
makeWebhookServerLayer(connector, options.webhook).pipe(Layer.provide(queueLayer)),
|
|
64
|
+
)
|
|
91
65
|
: Layer.empty;
|
|
92
66
|
|
|
93
67
|
return Effect.gen(function* () {
|
|
@@ -101,7 +75,9 @@ export function run(connector: ConnectorDefinition, options?: RunOptions) {
|
|
|
101
75
|
);
|
|
102
76
|
|
|
103
77
|
const sourceRun = runIngestion(connector, initialCutoff);
|
|
104
|
-
const webhookRun = options?.webhook
|
|
78
|
+
const webhookRun = options?.webhook
|
|
79
|
+
? runWebhookRuntime(connector, options.webhook.routes)
|
|
80
|
+
: Effect.void;
|
|
105
81
|
|
|
106
82
|
return yield* Effect.all([sourceRun, webhookRun], { concurrency: "unbounded" }).pipe(
|
|
107
83
|
Effect.asVoid,
|
|
@@ -109,38 +85,61 @@ export function run(connector: ConnectorDefinition, options?: RunOptions) {
|
|
|
109
85
|
}).pipe(Effect.provide(runtimeLayer));
|
|
110
86
|
}
|
|
111
87
|
|
|
112
|
-
const makeWebhookQueueLayer = (): Layer.Layer<WebhookQueue> =>
|
|
88
|
+
const makeWebhookQueueLayer = (connector: string): Layer.Layer<WebhookQueue> =>
|
|
113
89
|
Layer.effect(WebhookQueue)(
|
|
114
90
|
Effect.gen(function* () {
|
|
115
91
|
const queue = yield* Queue.bounded<QueuedWebhookBatch>(1024);
|
|
92
|
+
yield* Metrics.setWebhookQueueDepth(connector, 0);
|
|
116
93
|
return WebhookQueue.of({ queue });
|
|
117
94
|
}),
|
|
118
95
|
);
|
|
119
96
|
|
|
120
|
-
const runWebhookRuntime = (routes: ReadonlyArray<WebhookRoute>) => {
|
|
97
|
+
const runWebhookRuntime = (connector: ConnectorDefinition, routes: ReadonlyArray<WebhookRoute>) => {
|
|
121
98
|
const hasAfterEnqueue = routes.some((route) => route.ackMode === "after-enqueue");
|
|
122
|
-
return Effect.all(
|
|
123
|
-
|
|
124
|
-
|
|
99
|
+
return Effect.all(
|
|
100
|
+
[hasAfterEnqueue ? runWebhookQueueConsumer(connector) : Effect.void, Effect.never],
|
|
101
|
+
{
|
|
102
|
+
concurrency: "unbounded",
|
|
103
|
+
},
|
|
104
|
+
).pipe(Effect.asVoid);
|
|
125
105
|
};
|
|
126
106
|
|
|
127
|
-
const runWebhookQueueConsumer =
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
107
|
+
const runWebhookQueueConsumer = (connector: ConnectorDefinition) =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const queue = yield* WebhookQueue;
|
|
110
|
+
while (true) {
|
|
111
|
+
const batch = yield* Queue.take(queue.queue);
|
|
112
|
+
const depth = yield* Queue.size(queue.queue);
|
|
113
|
+
yield* Metrics.setWebhookQueueDepth(connector.name, depth);
|
|
114
|
+
yield* publishBatch({
|
|
115
|
+
connector: connector.name,
|
|
116
|
+
resource: batch.resource,
|
|
117
|
+
source: "webhook",
|
|
118
|
+
batch: batch.batch,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
134
122
|
|
|
135
|
-
const makeWebhookServerLayer = (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
123
|
+
const makeWebhookServerLayer = (
|
|
124
|
+
connector: ConnectorDefinition,
|
|
125
|
+
options: {
|
|
126
|
+
readonly routes: ReadonlyArray<WebhookRoute>;
|
|
127
|
+
readonly healthPath?: HttpRouter.PathInput;
|
|
128
|
+
readonly metricsPath?: HttpRouter.PathInput | false;
|
|
129
|
+
readonly statusPath?: HttpRouter.PathInput | false;
|
|
130
|
+
readonly disableHttpLogger?: boolean;
|
|
131
|
+
},
|
|
132
|
+
): Layer.Layer<never, never, HttpServer.HttpServer> => {
|
|
140
133
|
const healthPath: HttpRouter.PathInput = options.healthPath ?? "/health";
|
|
134
|
+
const metricsPath = options.metricsPath === undefined ? "/metrics" : options.metricsPath;
|
|
135
|
+
const statusPath = options.statusPath === undefined ? "/status" : options.statusPath;
|
|
141
136
|
const app = Layer.mergeAll(
|
|
142
|
-
router(options.routes),
|
|
137
|
+
router(options.routes, { connectorName: connector.name }),
|
|
143
138
|
HttpRouter.add("GET", healthPath, Effect.succeed(HttpServerResponse.text("ok"))),
|
|
139
|
+
...(metricsPath === false
|
|
140
|
+
? []
|
|
141
|
+
: [Observability.PrometheusMetrics.layerHttp({ path: metricsPath })]),
|
|
142
|
+
...(statusPath === false ? [] : [statusRoute(connector, statusPath)]),
|
|
144
143
|
);
|
|
145
144
|
|
|
146
145
|
return HttpRouter.serve(app, {
|
|
@@ -164,6 +163,7 @@ const initializeResourceState = (
|
|
|
164
163
|
existing: ResourceState | undefined,
|
|
165
164
|
initialCutoff: Cursor.Value,
|
|
166
165
|
): ResourceState => ({
|
|
166
|
+
...existing,
|
|
167
167
|
changes: existing?.changes ?? { cursor: initialCutoff },
|
|
168
168
|
backfill: existing?.backfill ?? {
|
|
169
169
|
cutoff: initialCutoff,
|
|
@@ -186,13 +186,119 @@ const runResourceSources = (
|
|
|
186
186
|
initialCutoff: Cursor.Value,
|
|
187
187
|
) => {
|
|
188
188
|
const runs = [
|
|
189
|
-
resource.backfill
|
|
190
|
-
|
|
189
|
+
resource.backfill
|
|
190
|
+
? isolateSourceFailure(
|
|
191
|
+
runBackfill(connector, resource, initialCutoff),
|
|
192
|
+
connector,
|
|
193
|
+
resource,
|
|
194
|
+
"backfill",
|
|
195
|
+
)
|
|
196
|
+
: Effect.void,
|
|
197
|
+
resource.changes
|
|
198
|
+
? isolateSourceFailure(
|
|
199
|
+
runChanges(connector, resource, initialCutoff),
|
|
200
|
+
connector,
|
|
201
|
+
resource,
|
|
202
|
+
"changes",
|
|
203
|
+
)
|
|
204
|
+
: Effect.void,
|
|
191
205
|
];
|
|
192
206
|
|
|
193
|
-
return
|
|
207
|
+
return initializeRuntimeStatus(connector, resource, initialCutoff).pipe(
|
|
208
|
+
Effect.andThen(Effect.all(runs, { concurrency: "unbounded" })),
|
|
209
|
+
Effect.asVoid,
|
|
210
|
+
Effect.catch(() =>
|
|
211
|
+
Metrics.setSyncState({ connector: connector.name, resource: resource.name }, "error").pipe(
|
|
212
|
+
Effect.andThen(Effect.logError("Connector resource initialization failed")),
|
|
213
|
+
Effect.annotateLogs({
|
|
214
|
+
[Attr.connectorName]: connector.name,
|
|
215
|
+
resource: resource.name,
|
|
216
|
+
}),
|
|
217
|
+
Effect.andThen(Effect.never),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
194
221
|
};
|
|
195
222
|
|
|
223
|
+
const recordResourceError =
|
|
224
|
+
(
|
|
225
|
+
connector: ConnectorDefinition,
|
|
226
|
+
resource: ResourceDefinition,
|
|
227
|
+
source: StateSource,
|
|
228
|
+
operation: StateOperation,
|
|
229
|
+
) =>
|
|
230
|
+
<A, R>(
|
|
231
|
+
effect: Effect.Effect<A, ConnectorError, R>,
|
|
232
|
+
): Effect.Effect<A, ConnectorError, R | StateStore> =>
|
|
233
|
+
effect.pipe(
|
|
234
|
+
Effect.tapError(() =>
|
|
235
|
+
Metrics.setSyncState({ connector: connector.name, resource: resource.name }, "error").pipe(
|
|
236
|
+
Effect.andThen(
|
|
237
|
+
StateStore.pipe(
|
|
238
|
+
Effect.flatMap((store) => store.setResourceError(resource.name, source, operation)),
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const isolateSourceFailure = <A, R>(
|
|
246
|
+
effect: Effect.Effect<A, ConnectorError, R>,
|
|
247
|
+
connector: ConnectorDefinition,
|
|
248
|
+
resource: ResourceDefinition,
|
|
249
|
+
source: StateSource,
|
|
250
|
+
): Effect.Effect<A, never, R> =>
|
|
251
|
+
// Only typed source errors are isolated. Defects and interruption still stop the runtime.
|
|
252
|
+
effect.pipe(
|
|
253
|
+
Effect.catch(() =>
|
|
254
|
+
Effect.logError("Connector source parked").pipe(
|
|
255
|
+
Effect.annotateLogs({
|
|
256
|
+
[Attr.connectorName]: connector.name,
|
|
257
|
+
resource: resource.name,
|
|
258
|
+
source,
|
|
259
|
+
}),
|
|
260
|
+
Effect.andThen(Effect.never),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const refreshRuntimeStatus = Effect.fnUntraced(function* (
|
|
266
|
+
connector: ConnectorDefinition,
|
|
267
|
+
resource: ResourceDefinition,
|
|
268
|
+
initialCutoff: Cursor.Value,
|
|
269
|
+
) {
|
|
270
|
+
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
271
|
+
yield* Metrics.setSyncState(
|
|
272
|
+
{ connector: connector.name, resource: resource.name },
|
|
273
|
+
deriveSyncState(resource, state),
|
|
274
|
+
);
|
|
275
|
+
return state;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const initializeRuntimeStatus = Effect.fnUntraced(function* (
|
|
279
|
+
connector: ConnectorDefinition,
|
|
280
|
+
resource: ResourceDefinition,
|
|
281
|
+
initialCutoff: Cursor.Value,
|
|
282
|
+
) {
|
|
283
|
+
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
284
|
+
yield* Metrics.setSyncState(
|
|
285
|
+
{ connector: connector.name, resource: resource.name },
|
|
286
|
+
deriveSyncState(resource, state),
|
|
287
|
+
);
|
|
288
|
+
if (resource.backfill) {
|
|
289
|
+
yield* Metrics.setLastSuccessTimestamp(
|
|
290
|
+
{ connector: connector.name, resource: resource.name, source: "backfill" },
|
|
291
|
+
state.backfill?.lastSuccessAt,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (resource.changes) {
|
|
295
|
+
yield* Metrics.setLastSuccessTimestamp(
|
|
296
|
+
{ connector: connector.name, resource: resource.name, source: "changes" },
|
|
297
|
+
state.changes?.lastSuccessAt,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
196
302
|
const runBackfill = Effect.fnUntraced(function* (
|
|
197
303
|
connector: ConnectorDefinition,
|
|
198
304
|
resource: ResourceDefinition,
|
|
@@ -202,39 +308,76 @@ const runBackfill = Effect.fnUntraced(function* (
|
|
|
202
308
|
const store = yield* StateStore;
|
|
203
309
|
let state = yield* getInitializedState(resource.name, initialCutoff);
|
|
204
310
|
|
|
311
|
+
if (state.backfill?.completed === true) {
|
|
312
|
+
// The checkpoint may have committed before the previous process cleared
|
|
313
|
+
// its error marker. It may also be hidden by a newer changes error.
|
|
314
|
+
yield* store
|
|
315
|
+
.clearResourceError(resource.name, "backfill")
|
|
316
|
+
.pipe(recordResourceError(connector, resource, "backfill", "checkpoint"));
|
|
317
|
+
yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
205
321
|
while (state.backfill?.completed !== true) {
|
|
206
322
|
const backfill = state.backfill ?? { cutoff: initialCutoff, completed: false };
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
323
|
+
const clearsVisibleError = state.lastError?.source === "backfill";
|
|
324
|
+
const page = yield* resource.backfill
|
|
325
|
+
.fetch({
|
|
326
|
+
pageCursor: backfill.pageCursor,
|
|
327
|
+
cutoff: backfill.cutoff,
|
|
328
|
+
})
|
|
329
|
+
.pipe(recordResourceError(connector, resource, "backfill", "fetch"));
|
|
211
330
|
|
|
212
331
|
yield* publishBatch({
|
|
213
|
-
connector,
|
|
332
|
+
connector: connector.name,
|
|
214
333
|
resource: resource.name,
|
|
215
334
|
source: "backfill",
|
|
216
335
|
batch: {
|
|
217
336
|
cursor: page.nextPageCursor ?? backfill.cutoff,
|
|
218
337
|
mutations: page.mutations,
|
|
219
338
|
},
|
|
220
|
-
});
|
|
339
|
+
}).pipe(recordResourceError(connector, resource, "backfill", "publish"));
|
|
221
340
|
|
|
341
|
+
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
222
342
|
state = {
|
|
223
343
|
...state,
|
|
224
344
|
backfill: {
|
|
225
345
|
cutoff: backfill.cutoff,
|
|
226
346
|
pageCursor: page.nextPageCursor,
|
|
227
347
|
completed: !page.hasMore,
|
|
348
|
+
lastSuccessAt,
|
|
228
349
|
},
|
|
229
350
|
};
|
|
230
351
|
|
|
352
|
+
// Checkpoint only after publish succeeds so a crash cannot skip an
|
|
353
|
+
// unacknowledged page; replay therefore remains at-least-once.
|
|
231
354
|
yield* Effect.withSpan(
|
|
232
355
|
store
|
|
233
|
-
.
|
|
234
|
-
|
|
356
|
+
.setBackfillState(resource.name, {
|
|
357
|
+
cutoff: normalizeCursor(backfill.cutoff),
|
|
358
|
+
pageCursor:
|
|
359
|
+
page.nextPageCursor === undefined ? undefined : normalizeCursor(page.nextPageCursor),
|
|
360
|
+
completed: !page.hasMore,
|
|
361
|
+
lastSuccessAt,
|
|
362
|
+
})
|
|
363
|
+
.pipe(
|
|
364
|
+
Effect.andThen(
|
|
365
|
+
Metrics.setLastSuccessTimestamp(
|
|
366
|
+
{ connector: connector.name, resource: resource.name, source: "backfill" },
|
|
367
|
+
lastSuccessAt,
|
|
368
|
+
),
|
|
369
|
+
),
|
|
370
|
+
Effect.andThen(store.clearResourceError(resource.name, "backfill")),
|
|
371
|
+
Effect.tapError((error) => annotateError("state_set", error)),
|
|
372
|
+
),
|
|
235
373
|
SpanName.stateSet,
|
|
236
374
|
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
237
|
-
);
|
|
375
|
+
).pipe(recordResourceError(connector, resource, "backfill", "checkpoint"));
|
|
376
|
+
|
|
377
|
+
// Completion or clearing an error can change the resource-wide sync state.
|
|
378
|
+
if (clearsVisibleError || !page.hasMore) {
|
|
379
|
+
state = yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
380
|
+
}
|
|
238
381
|
}
|
|
239
382
|
});
|
|
240
383
|
|
|
@@ -249,77 +392,47 @@ const runChanges = Effect.fnUntraced(function* (
|
|
|
249
392
|
while (true) {
|
|
250
393
|
const state = yield* getInitializedState(resource.name, initialCutoff);
|
|
251
394
|
const cursor = state.changes?.cursor ?? initialCutoff;
|
|
252
|
-
const
|
|
395
|
+
const clearsVisibleError = state.lastError?.source === "changes";
|
|
396
|
+
const page = yield* resource.changes
|
|
397
|
+
.fetch({ cursor })
|
|
398
|
+
.pipe(recordResourceError(connector, resource, "changes", "fetch"));
|
|
253
399
|
|
|
254
400
|
yield* publishBatch({
|
|
255
|
-
connector,
|
|
401
|
+
connector: connector.name,
|
|
256
402
|
resource: resource.name,
|
|
257
403
|
source: "changes",
|
|
258
404
|
batch: {
|
|
259
405
|
cursor: page.cursor,
|
|
260
406
|
mutations: page.mutations,
|
|
261
407
|
},
|
|
262
|
-
});
|
|
408
|
+
}).pipe(recordResourceError(connector, resource, "changes", "publish"));
|
|
263
409
|
|
|
410
|
+
const lastSuccessAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso));
|
|
411
|
+
// Advance the durable cursor only after Wings accepts the page.
|
|
264
412
|
yield* Effect.withSpan(
|
|
265
413
|
store
|
|
266
|
-
.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
414
|
+
.setChangesState(resource.name, {
|
|
415
|
+
cursor: normalizeCursor(page.cursor),
|
|
416
|
+
lastSuccessAt,
|
|
417
|
+
})
|
|
418
|
+
.pipe(
|
|
419
|
+
Effect.andThen(
|
|
420
|
+
Metrics.setLastSuccessTimestamp(
|
|
421
|
+
{ connector: connector.name, resource: resource.name, source: "changes" },
|
|
422
|
+
lastSuccessAt,
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
Effect.andThen(store.clearResourceError(resource.name, "changes")),
|
|
426
|
+
Effect.tapError((error) => annotateError("state_set", error)),
|
|
427
|
+
),
|
|
274
428
|
SpanName.stateSet,
|
|
275
429
|
{ attributes: { [Attr.stateKey]: resource.name } },
|
|
276
|
-
);
|
|
430
|
+
).pipe(recordResourceError(connector, resource, "changes", "checkpoint"));
|
|
277
431
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
432
|
+
if (clearsVisibleError) {
|
|
433
|
+
yield* refreshRuntimeStatus(connector, resource, initialCutoff);
|
|
434
|
+
}
|
|
281
435
|
|
|
282
|
-
|
|
283
|
-
readonly connector?: ConnectorDefinition;
|
|
284
|
-
readonly resource: string;
|
|
285
|
-
readonly source: "backfill" | "changes" | "webhook";
|
|
286
|
-
readonly batch: ResourceBatch;
|
|
287
|
-
}) {
|
|
288
|
-
const metric = {
|
|
289
|
-
connector: options.connector?.name ?? "unknown",
|
|
290
|
-
resource: options.resource,
|
|
291
|
-
source: options.source,
|
|
292
|
-
};
|
|
293
|
-
yield* Metric.update(Metric.withAttributes(connectorBatchesTotal, metric), 1);
|
|
294
|
-
yield* Metric.update(
|
|
295
|
-
Metric.withAttributes(connectorMutationsTotal, metric),
|
|
296
|
-
options.batch.mutations.length,
|
|
297
|
-
);
|
|
298
|
-
yield* Metric.update(
|
|
299
|
-
Metric.withAttributes(connectorBatchSize, metric),
|
|
300
|
-
options.batch.mutations.length,
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
const publisher = yield* Publisher;
|
|
304
|
-
const ack = yield* publisher.publish({
|
|
305
|
-
resource: options.resource,
|
|
306
|
-
source: options.source,
|
|
307
|
-
batch: options.batch,
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
yield* Effect.annotateCurrentSpan({ [Attr.publisherSuccess]: ack.status === "accepted" });
|
|
311
|
-
if (ack.status === "rejected") {
|
|
312
|
-
yield* Effect.annotateCurrentSpan({ [Attr.errorPhase]: "publish" });
|
|
313
|
-
return yield* Effect.fail(
|
|
314
|
-
new ConnectorError({
|
|
315
|
-
message: `Publisher rejected batch for ${options.resource}: ${ack.reason}`,
|
|
316
|
-
}),
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (options.batch.cursor !== undefined) {
|
|
321
|
-
yield* addCurrentSpanEvent(EventName.batchCheckpoint, {
|
|
322
|
-
[EventAttr.batchCursor]: options.batch.cursor,
|
|
323
|
-
});
|
|
436
|
+
yield* Effect.sleep(resource.changes.interval ?? "1 minute");
|
|
324
437
|
}
|
|
325
438
|
});
|