Skip to content

Commit cc5586d

Browse files
fix(service-automation): converge a connector definition change on every replica (#28028)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D2HoQfwCF75b2EvnX1Ze8Uc)_ Fixes #28002 Clause-②: no Metadata-protocol: none Skills-docs: none in skills (`git grep -n -i -E "replica|cluster|metadata\.mutated|peer" -- skills/ | grep -i connector` finds 0 lines); docs follow-up owed and outside this card's file surface: `content/docs/kernel/cluster.mdx` §6.2 (the lane 2 row's "Converges" column and the "Lane 2" subsection name only ObjectQL's object registry, while the automation service's flows and, after this PR, its materialized connectors converge on lane 2 as well) and `content/docs/automation/connectors.mdx` (the fault table's "runtime reload (Studio publish / dev reload)" row now also covers a direct active save and a change made on another replica) ## 维护者速读 - 改了什么:在一个副本上保存、发布或删除连接器定义后,其它副本现在也会重新物化该连接器;直接 `PUT /api/v1/meta/connector/:name`(不走发布门)也会让写入的那个副本自己生效。 - 为什么改:之前只有发布的那台副本通过本机的 `metadata:reloaded` 重建实例,其它副本虽然经 lane 2 收到了变更,但自动化插件的监听器只处理 `flow`,于是一直用旧定义直到重载或重启。 - 风险与回滚:只是让已有监听器对 `connector` 变更多排一次「软」对账(与 reload 同一路径),不新增集群通道;发布方会对账两次,第二次签名不变、不重建(已测)。回滚即还原本 PR 的 `plugin.ts`。 - 先看:`packages/services/service-automation/src/plugin.ts`(`subscribeFlowMutations` 里新加的 `connector` 分支与新方法 `queueConnectorMutationReconcile`);`connector-definition-cluster.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/connector-definition-cluster.test.ts`,预期 7/7 通过。 - 需要你做的:无,席位落地。 ## What changes `AutomationServicePlugin` subscribes to the metadata protocol's post-persistence signal (`onMetadataMutation`, #21725), and its listener returned for every type but `flow`. A connector definition change therefore re-materialized only where `metadata:reloaded` fired: on the replica that published it (`ObjectQLPlugin.announcePerItemPublish`). That hook is kernel-local. Lane 2 (`metadata.mutated`) did deliver `connector/NAME` into a peer's mutation listeners, as #27977 measured, and nothing there acted on it. Now the listener also handles a `connector` mutation whose state is not `draft` (`active` or `deleted`) by queuing `queueConnectorMutationReconcile`: the soft reconcile `metadata:reloaded` runs (`reconcileDeclaredConnectors(ctx, { fatal: false })`), on the existing reconcile queue, so it never interleaves with a credential write or another reconcile. The reconcile re-reads the declaration from this replica's own protocol view, the same read the reload path makes. No new lane, channel or payload: a peer converges on the lane 2 replay it already receives (triage 6517791280, §6.2's single-signal rule). - Coalesced: while one mutation-queued reconcile is waiting to start, further connector mutations add nothing. The flag clears when the run starts, so a mutation reported during a run queues one more. - A failure is logged (`warn`, cause in `meta` per #5575) and leaves the next reload or restart to reconcile, as lane 4's receipt does. - Flow mutations take the same path as before. Every other type is still ignored. ## Tests `packages/services/service-automation/src/connector-definition-cluster.test.ts` (the #27977 two-kernel harness: real `ObjectKernel`, `ObjectQLPlugin`, driver-sql on one shared better-sqlite3 file, lanes 2 and 4 attached as the cluster bridge attaches them): - The #27977 `it.fails` pin is now a plain `it`: the peer follows a published definition with no reload on the peer. - New: the publishing replica reconciles from both of its signals. For two publishes it runs 4 reconciles (its own `metadata:reloaded` plus its own mutation listener; lane 2's loopback guard drops the bus echo), and the provider is called exactly `[V1, V2]`, so the second run is a no-op. - New, the card's side finding: a direct active save (`saveMetaItem` with no mode, the call behind `PUT /api/v1/meta/connector/:name`) re-materializes the writing replica and converges the peer. The same listener covers it, so it is pinned here and not widened elsewhere. - New: a deleted connector is torn down on both replicas. - New control: a draft connector save and a flow save run no connector reconcile on either replica (counted through a spy on `reconcileDeclaredConnectors`, attributed per plugin instance), the provider is not called again, and the flow save still arms the flow on both replicas. - The existing reload control now runs the peer with lane 2 off, so a `metadata:reloaded` is again its only trigger: the peer holds nothing before the reload and converges from its own read after it. ## Acceptance notes - The reconcile reads the protocol's execution view with no organization, as the reload reconcile does, so an org-scoped connector mutation converges exactly as far as a reload would. - A held instance (a refused credential, or since #28014 a configuration fault met on a retry) is skipped by an unchanged-signature reconcile, as on a reload. An edit of its declaration re-attempts it, now on every replica. - The descriptor audit (`auditDeclaredConnectors`, the inert-descriptor warning) still runs at `kernel:ready` and on `metadata:reloaded` only. This PR adds convergence, not another warning per save. ## Verification All readings at head `89267c8b` (branched off `main` at `066ff51e`, after #28014 merged). <details><summary>Package tests and typecheck</summary> - `pnpm --filter @objectstack/service-automation typecheck` (under `scripts/pm/os-verify-lock.sh`): exit 0, `check:test-typecheck: OK … 0 file(s) / 0 error(s)`. The test layer compiles under `tsconfig.test.json`, which includes `src/**/*.test.ts`. - `pnpm --filter @objectstack/service-automation test`: `Test Files 223 passed (223)`, `Tests 2846 passed (2846)`, lock `VERDICT command-exit 0`. - `vitest run src/connector-definition-cluster.test.ts`: `Tests 7 passed (7)`. </details> <details><summary>Ablation and restore</summary> Through `scripts/ablation-replace.mjs` on `plugin.ts`: the anchor `this.queueConnectorMutationReconcile(ctx);` plus the `return;` line after it, replaced by `/* ABLATION-28002 */ return;` (a connector mutation is then dropped, as on `main`). Anchor x1 to x0, marker x0 to x1, blob `154b06fea8a2` to `9852d936898e`. The suite imports `./plugin.js` from source, so no rebuild is in the path. - Mutated run: `Tests 5 failed | 2 passed (7)`. Red: the flipped peer pin, the publishing-replica count, the direct save, the delete, and the draft/flow control (on its setup line, `expected undefined to be 'https://v1.billing.example.test'`: the peer never converges, so the control's precondition cannot hold). Green: the lane 2 reach row and the lane-2-off reload control, which do not depend on the listener. - Restore: blob after restore equals the blob at HEAD (`154b06fea8a2`), and `git diff HEAD` is empty. </details> <details><summary>Derived gate families and lint</summary> `node scripts/pm/dispatch-gates.mjs --commands` derived 60 families at `89267c8b`. All were run, plus `pnpm lint` (exit 0). `--ran`: `60 derived famil(ies) accounted for — 59 run, 1 NOT-MEASURED`. - NOT MEASURED: `pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET (it needs every package's `dist`; only the `@objectstack/service-automation^...` closure was built). CI runs it. - Every other derived family: exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cxbvGGSLgjTAqnkkMK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f296334 commit cc5586d

3 files changed

Lines changed: 196 additions & 19 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a connector definition saved, published or deleted on one replica now converges every replica
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A connector instance definition changed through the per-item publish door re-materialized only on the replica that published it: that replica's `metadata:reloaded` hook ran the connector reconcile, but the hook is kernel-local, and the cluster's metadata-mutation lane (lane 2, `metadata.mutated`) delivered the change to the other replicas' mutation listeners, where the automation plugin acted on `flow` mutations only. Peers kept serving the old definition until a reload or a restart. A direct active save (`PUT /api/v1/meta/connector/:name`) raised no reload at all, so it did not re-materialize even the replica that wrote it.
11+
12+
The automation plugin's metadata-mutation listener now queues the same soft reconcile `metadata:reloaded` runs for a `connector` mutation that is not a draft. No new cluster channel is added: a peer converges on the lane 2 replay it already receives, re-reading the shared store.
13+
14+
- A publish on the writing replica reconciles from both of its signals; the second run finds the instance unchanged, so the provider is still called once per changed definition.
15+
- A direct active save re-materializes the writing replica and converges its peers. A deleted connector is torn down on every replica.
16+
- A draft save, and a mutation of any other type, runs no connector reconcile. Flow mutations are handled as before.

‎packages/services/service-automation/src/connector-definition-cluster.test.ts‎

Lines changed: 134 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
/**
44
* [#27977] Cross-replica reach of a connector DEFINITION published through the
5-
* per-item publish door — measured over the topology of
5+
* per-item publish door — measured, and since #28002 pinned converging, over
6+
* the topology of
67
* `connector-credential-cluster.test.ts`: two kernels ("replicas") over ONE
78
* shared database, each with its own materialized connectors, joined by a bus
89
* that delivers every publish to every subscriber (publisher included).
@@ -22,13 +23,25 @@
2223
* • lane 2 carries the mutation to B: B's protocol replays it into B's
2324
* mutation listeners -> GREEN
2425
* • B's live instance follows the new definition with no reload on B
25-
* -> RED
26-
* (the automation plugin's mutation listener takes `flow` only, and
27-
* `metadata:reloaded` is a kernel-local hook; pinned as `it.fails`, the
28-
* fix is #28002)
26+
* -> GREEN
27+
* (measured RED by #27977: the automation plugin's mutation listener took
28+
* `flow` only, and `metadata:reloaded` is a kernel-local hook. #28002
29+
* widens that listener: a `connector` mutation, lane 2's replay included,
30+
* queues the reconcile `metadata:reloaded` runs)
2931
* • control: a `metadata:reloaded` on B converges B from its OWN read of the
3032
* shared store, so the gap is the trigger and not the store or the read
3133
* -> GREEN
34+
*
35+
* [#28002] Added beside them:
36+
* • A publishes, so A reconciles from its own `metadata:reloaded` AND its own
37+
* mutation listener: the provider still runs once per changed definition
38+
* -> GREEN
39+
* • a direct active save (`PUT /api/v1/meta/connector/:name`, no publish
40+
* door) re-materializes the writing replica and converges the peer
41+
* -> GREEN
42+
* • a deleted connector is torn down on both replicas -> GREEN
43+
* • controls: a draft save and a flow save run no connector reconcile; the
44+
* flow save still arms the flow -> GREEN
3245
*/
3346
import { mkdtempSync, rmSync } from 'node:fs';
3447
import { tmpdir } from 'node:os';
@@ -59,10 +72,24 @@ const definition = (endpoint: string) => ({
5972
providerConfig: { endpoint },
6073
});
6174

62-
/** The protocol surface this suite drives: the doors `PUT /meta/:type/:name` and `POST …/publish` call. */
75+
/** A minimal start → end flow, saved active: the control's other metadata type. */
76+
const plainFlow = (name: string) => ({
77+
name,
78+
label: name,
79+
type: 'autolaunched',
80+
status: 'active',
81+
nodes: [
82+
{ id: 'start', type: 'start', label: 'Start' },
83+
{ id: 'end', type: 'end', label: 'End' },
84+
],
85+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
86+
});
87+
88+
/** The protocol surface this suite drives: the doors `PUT /meta/:type/:name`, `POST …/publish` and `DELETE` call. */
6389
interface Protocol {
6490
saveMetaItem(request: { type: string; name: string; item: unknown; mode?: 'draft' | 'publish' }): Promise<unknown>;
6591
publishMetaItem(request: { type: string; name: string }): Promise<unknown>;
92+
deleteMetaItem(request: { type: string; name: string }): Promise<unknown>;
6693
onMetadataMutation(listener: (evt: { type: string; name: string; state?: string }) => void): () => void;
6794
attachMetadataMutationPubSub(pubsub: IPubSub, nodeId: string): () => void;
6895
}
@@ -105,14 +132,20 @@ function sharedBus(): IPubSub {
105132
};
106133
}
107134

135+
/** Every connector reconcile, attributed to its plugin instance through `mock.contexts`. */
136+
const reconcileSpy = vi.spyOn(
137+
AutomationServicePlugin.prototype as unknown as { reconcileDeclaredConnectors(...args: unknown[]): Promise<void> },
138+
'reconcileDeclaredConnectors',
139+
);
140+
108141
const kernels: ObjectKernel[] = [];
109142
const dirs: string[] = [];
110143
afterEach(async () => {
111144
for (const k of kernels.splice(0)) {
112145
if (k.getState() === 'running') await k.shutdown().catch(() => {});
113146
}
114147
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
115-
vi.restoreAllMocks();
148+
reconcileSpy.mockClear();
116149
});
117150

118151
/** The ONE database file both replicas open. */
@@ -122,14 +155,19 @@ function sharedDatabaseFile(): string {
122155
return join(dir, 'shared.sqlite');
123156
}
124157

125-
/** One replica over the shared file, attached to `bus` the way the cluster bridge attaches it. */
126-
async function replica(file: string, nodeId: string, bus: IPubSub) {
158+
/**
159+
* One replica over the shared file, attached to `bus` the way the cluster
160+
* bridge attaches it. `lane2: false` leaves lane 2 off, the reach #27977
161+
* measured a peer as having for this signal.
162+
*/
163+
async function replica(file: string, nodeId: string, bus: IPubSub, opts: { lane2?: boolean } = {}) {
127164
const provider = fakeProvider();
128165
let hooks: { trigger(event: string, payload?: unknown): Promise<void> } | undefined;
129166
const kernel = new ObjectKernel({ logger: { level: 'fatal' }, gracefulShutdown: false } as never);
130167
kernels.push(kernel);
131168
await kernel.use(new ObjectQLPlugin());
132-
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory', credentialResolver: () => undefined }));
169+
const plugin = new AutomationServicePlugin({ suspendedRunStore: 'memory', credentialResolver: () => undefined });
170+
await kernel.use(plugin);
133171
await kernel.use({
134172
name: `test.harness.${nodeId}`,
135173
type: 'standard' as const,
@@ -153,7 +191,7 @@ async function replica(file: string, nodeId: string, bus: IPubSub) {
153191
const protocol = kernel.getService<Protocol>('protocol');
154192
const automation = kernel.getService<AutomationEngine>('automation');
155193
// What `MetadataClusterBridgePlugin`'s lanes 2 and 4 do at `kernel:ready` on a cross-process driver.
156-
protocol.attachMetadataMutationPubSub(bus, nodeId);
194+
if (opts.lane2 !== false) protocol.attachMetadataMutationPubSub(bus, nodeId);
157195
automation.attachConnectorCredentialPubSub(bus, nodeId);
158196

159197
const heard: string[] = [];
@@ -164,9 +202,16 @@ async function replica(file: string, nodeId: string, bus: IPubSub) {
164202
await protocol.saveMetaItem({ type: 'connector', name: NAME, item, mode: 'draft' });
165203
await protocol.publishMetaItem({ type: 'connector', name: NAME });
166204
};
205+
/** The direct door: `PUT /api/v1/meta/connector/:name`, saved active. */
206+
const save = (item: unknown) => protocol.saveMetaItem({ type: 'connector', name: NAME, item });
207+
const saveDraft = (item: unknown) => protocol.saveMetaItem({ type: 'connector', name: NAME, item, mode: 'draft' });
208+
const remove = () => protocol.deleteMetaItem({ type: 'connector', name: NAME });
209+
const saveFlow = (name: string) => protocol.saveMetaItem({ type: 'flow', name, item: plainFlow(name) });
167210
const reload = () => hooks!.trigger('metadata:reloaded', { changed: [`connector/${NAME}`] });
168211
const live = () => automation.getRegisteredConnectors().includes(NAME);
169-
return { provider, publish, reload, live, heard };
212+
/** Connector reconciles this replica ran — the run `metadata:reloaded` queues. */
213+
const reconciles = () => reconcileSpy.mock.contexts.filter((c) => c === plugin).length;
214+
return { provider, publish, save, saveDraft, remove, saveFlow, reload, live, heard, reconciles, automation };
170215
}
171216

172217
describe('[#27977] a connector definition published on one replica', () => {
@@ -185,8 +230,8 @@ describe('[#27977] a connector definition published on one replica', () => {
185230
await vi.waitFor(() => expect(b.heard).toContain(`connector/${NAME}`));
186231
});
187232

188-
// Measured by #27977; the fix is #28002. Promote to a plain `it` when #28002 lands.
189-
it.fails('[#27977] converges the peer with no reload on the peer — known gap, filed as #28002', async () => {
233+
// Measured RED by #27977 (pinned `it.fails`); converges since #28002.
234+
it('[#28002] converges the peer with no reload on the peer', async () => {
190235
const file = sharedDatabaseFile();
191236
const bus = sharedBus();
192237
const a = await replica(file, 'node-a', bus);
@@ -198,17 +243,89 @@ describe('[#27977] a connector definition published on one replica', () => {
198243
await vi.waitFor(() => expect(b.provider.last()).toBe(V2), { timeout: 500 });
199244
});
200245

201-
it('control: a reload on the peer converges it from its own read of the shared store', async () => {
246+
it('[#28002] the publishing replica materializes each changed definition once, though both its signals reconcile', async () => {
202247
const file = sharedDatabaseFile();
203248
const bus = sharedBus();
204249
const a = await replica(file, 'node-a', bus);
205250
const b = await replica(file, 'node-b', bus);
251+
const before = a.reconciles();
252+
253+
await a.publish(definition(V1));
254+
await a.publish(definition(V2));
255+
await vi.waitFor(() => expect(b.provider.last()).toBe(V2), { timeout: 2000 });
256+
for (let i = 0; i < 5; i++) await flush();
257+
258+
// Two reconciles per publish on A — its own `metadata:reloaded` and its
259+
// own mutation listener (lane 2's loopback guard drops the bus echo) —
260+
// and the second finds the signature unchanged.
261+
expect(a.reconciles() - before).toBe(4);
262+
expect(a.provider.endpoints).toEqual([V1, V2]);
263+
expect(a.live()).toBe(true);
264+
});
265+
266+
it('[#28002] a direct active save re-materializes the writing replica and converges the peer', async () => {
267+
const file = sharedDatabaseFile();
268+
const bus = sharedBus();
269+
const a = await replica(file, 'node-a', bus);
270+
const b = await replica(file, 'node-b', bus);
271+
272+
await a.save(definition(V1));
273+
await vi.waitFor(() => expect(a.provider.last()).toBe(V1), { timeout: 2000 });
274+
await vi.waitFor(() => expect(b.provider.last()).toBe(V1), { timeout: 2000 });
275+
276+
await a.save(definition(V2));
277+
await vi.waitFor(() => expect(a.provider.last()).toBe(V2), { timeout: 2000 });
278+
await vi.waitFor(() => expect(b.provider.last()).toBe(V2), { timeout: 2000 });
279+
expect(a.live()).toBe(true);
280+
expect(b.live()).toBe(true);
281+
});
282+
283+
it('[#28002] a deleted connector is torn down on both replicas', async () => {
284+
const file = sharedDatabaseFile();
285+
const bus = sharedBus();
286+
const a = await replica(file, 'node-a', bus);
287+
const b = await replica(file, 'node-b', bus);
288+
289+
await a.publish(definition(V1));
290+
await vi.waitFor(() => expect(b.live()).toBe(true), { timeout: 2000 });
291+
292+
await a.remove();
293+
await vi.waitFor(() => expect(a.live()).toBe(false), { timeout: 2000 });
294+
await vi.waitFor(() => expect(b.live()).toBe(false), { timeout: 2000 });
295+
});
296+
297+
it('[#28002] control: a draft connector save and a flow save run no connector reconcile; the flow still arms', async () => {
298+
const file = sharedDatabaseFile();
299+
const bus = sharedBus();
300+
const a = await replica(file, 'node-a', bus);
301+
const b = await replica(file, 'node-b', bus);
302+
await a.publish(definition(V1));
303+
await vi.waitFor(() => expect(b.provider.last()).toBe(V1), { timeout: 2000 });
304+
for (let i = 0; i < 5; i++) await flush();
305+
const [beforeA, beforeB] = [a.reconciles(), b.reconciles()];
306+
307+
await a.saveDraft(definition(V2));
308+
await a.saveFlow('flow_28002');
309+
await vi.waitFor(async () => expect(await a.automation.getFlow('flow_28002')).not.toBeNull(), { timeout: 2000 });
310+
await vi.waitFor(async () => expect(await b.automation.getFlow('flow_28002')).not.toBeNull(), { timeout: 2000 });
311+
for (let i = 0; i < 5; i++) await flush();
312+
313+
expect([a.reconciles() - beforeA, b.reconciles() - beforeB]).toEqual([0, 0]);
314+
expect([a.provider.endpoints, b.provider.endpoints]).toEqual([[V1], [V1]]);
315+
});
316+
317+
it('control: a reload on the peer converges it from its own read of the shared store', async () => {
318+
const file = sharedDatabaseFile();
319+
const bus = sharedBus();
320+
const a = await replica(file, 'node-a', bus);
321+
// Lane 2 off on the peer: the reload is then its only trigger.
322+
const b = await replica(file, 'node-b', bus, { lane2: false });
206323

207324
await a.publish(definition(V1));
208325
await a.publish(definition(V2));
209-
await vi.waitFor(() => expect(b.heard).toContain(`connector/${NAME}`));
210326
for (let i = 0; i < 5; i++) await flush();
211-
// The measured state before the reload: the peer never materialized the instance.
327+
// The state before the reload: the peer never materialized the instance.
328+
expect(b.heard).toEqual([]);
212329
expect(b.live()).toBe(false);
213330
expect(b.provider.endpoints).toEqual([]);
214331

‎packages/services/service-automation/src/plugin.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,13 @@ export class AutomationServicePlugin implements Plugin {
842842
* registration per publish, not two.
843843
*/
844844
private mutationArmedFlows = new Map<string, string>();
845-
/** [#21725] The `onMetadataMutation` subscription; dropped at destroy. */
845+
/**
846+
* [#28002] A connector reconcile the metadata mutation signal queued and
847+
* that has not started yet: every connector mutation reported before it
848+
* starts is answered by that one run, which re-reads the whole declaration.
849+
*/
850+
private connectorMutationReconcilePending = false;
851+
/** [#21725] The `onMetadataMutation` subscription (flows, and connectors since #28002); dropped at destroy. */
846852
private unsubscribeFlowMutations?: () => void;
847853

848854
constructor(options: AutomationServicePluginOptions = {}) {
@@ -3642,6 +3648,9 @@ export class AutomationServicePlugin implements Plugin {
36423648
* run on the flow sync queue ({@link syncMutatedFlows}). The save's own
36433649
* answer therefore precedes the arming by that one read.
36443650
*
3651+
* [#28002] A `connector` mutation queues a connector reconcile instead
3652+
* ({@link queueConnectorMutationReconcile}); every other type is ignored.
3653+
*
36453654
* Resolved at `start()`, once every plugin has inited, so a protocol that
36463655
* registers after this plugin is seen.
36473656
*/
@@ -3668,15 +3677,50 @@ export class AutomationServicePlugin implements Plugin {
36683677
return;
36693678
}
36703679
this.unsubscribeFlowMutations = protocol.onMetadataMutation((evt) => {
3671-
if (evt?.type !== 'flow' || evt.state === 'draft') return;
3680+
if (!evt || evt.state === 'draft') return;
36723681
if (typeof evt.name !== 'string' || evt.name === '') return;
3682+
if (evt.type === 'connector') {
3683+
this.queueConnectorMutationReconcile(ctx);
3684+
return;
3685+
}
3686+
if (evt.type !== 'flow') return;
36733687
const idle = this.pendingFlowMutations.size === 0;
36743688
this.pendingFlowMutations.add(evt.name);
36753689
// One queued sync drains every name reported before it starts.
36763690
if (idle) void this.enqueueFlowSync(() => this.syncMutatedFlows(ctx)).catch(() => undefined);
36773691
});
36783692
}
36793693

3694+
/**
3695+
* [#28002] Reconcile this replica's connectors after a connector was saved
3696+
* active, published or deleted — the reconcile `metadata:reloaded` runs
3697+
* ({@link materializeDeclaredConnectors}, soft mode), on the reconcile
3698+
* queue. The mutation signal is what reaches a PEER: lane 2 of the cluster
3699+
* family replays a write made on another replica into this protocol's
3700+
* listeners, while `metadata:reloaded` is a kernel-local hook. It also
3701+
* covers a direct active save (`PUT /api/v1/meta/connector/:name`), which
3702+
* raises no reload anywhere.
3703+
*
3704+
* A publish on this replica raises both signals; the second reconcile
3705+
* finds every signature unchanged and leaves the instances alone.
3706+
* Coalesced: a reconcile queued and not yet started answers every
3707+
* connector mutation reported before it starts.
3708+
*/
3709+
private queueConnectorMutationReconcile(ctx: PluginContext): void {
3710+
if (this.connectorMutationReconcilePending) return;
3711+
this.connectorMutationReconcilePending = true;
3712+
void this.onReconcileQueue(async () => {
3713+
this.connectorMutationReconcilePending = false;
3714+
await this.reconcileDeclaredConnectors(ctx, { fatal: false });
3715+
}).catch((err) => {
3716+
ctx.logger.warn(
3717+
'[Automation] reconciling the connectors after a connector metadata change failed; '
3718+
+ 'the next metadata reload or restart reconciles them.',
3719+
describeThrownForLog(err),
3720+
);
3721+
});
3722+
}
3723+
36803724
/**
36813725
* [#21725] Make the engine's registration of each reported flow follow its
36823726
* stored state — what a restart would arm, without the restart:

0 commit comments

Comments
 (0)