@reventlessdev/reventless-core 3.0.0-alpha.171 → 3.0.0-alpha.172

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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.172 (2026-07-17)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **plugin:** scale heartbeat disconnect grace and enforce single-source interval ([7ba8fb7](https://github.com/ReventlessDev/reventless-core/commit/7ba8fb7bfbe023aba7b538329d2489f939b7fcea))
11
+
12
+
6
13
  # 3.0.0-alpha.171 (2026-07-17)
7
14
 
8
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.171",
3
+ "version": "3.0.0-alpha.172",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -30,14 +30,14 @@
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.29",
32
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
33
- "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
34
33
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
36
34
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
- "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
35
+ "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
36
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
38
37
  "@reventlessdev/reventless-infra": "3.0.0-alpha.102",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.28",
40
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.28",
40
+ "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
41
41
  "@reventlessdev/reventless-spec": "3.0.0-alpha.79"
42
42
  },
43
43
  "devDependencies": {
@@ -11,13 +11,13 @@ module type T = {
11
11
  let connect: (
12
12
  ~runtime: Runtime.environment<runtimeParts>,
13
13
  ~remoteChannel: CommandTopic_Adapter.remoteChannel,
14
- ~timeout: int=?,
14
+ ~timeout: int,
15
15
  component,
16
16
  ) => unit
17
17
 
18
18
  let makeHandler: (
19
19
  ~id: string,
20
- ~timeout: int=?,
20
+ ~timeout: int,
21
21
  ~publishToPluginExtensionPoint: CommandTopic.publishJsons,
22
22
  ) => Pulumi.Output.t<Runtime.eventHandler<unit, 'context, unit>>
23
23
 
@@ -5,7 +5,7 @@ module Make = (Runner: Heartbeat_Adapter.Runner): (
5
5
 
6
6
  let construct = (_self, _name) => ()
7
7
 
8
- let connect = (~runtime, ~remoteChannel, ~timeout=10, heartbeat) => {
8
+ let connect = (~runtime, ~remoteChannel, ~timeout, heartbeat) => {
9
9
  let heartbeatResource = heartbeat->Component.toPulumiResource
10
10
  let name =
11
11
  heartbeatResource.name
@@ -24,7 +24,7 @@ module Make = (Runner: Heartbeat_Adapter.Runner): (
24
24
  let _ = heartbeat->Component.setOutputs(outputs)
25
25
  }
26
26
 
27
- let makeHandler = (~id, ~timeout=10, ~publishToPluginExtensionPoint) => {
27
+ let makeHandler = (~id, ~timeout, ~publishToPluginExtensionPoint) => {
28
28
  module Callback = Heartbeat_Callback.Make({
29
29
  let publishToPluginExtensionPoint = publishToPluginExtensionPoint
30
30
  let id = id
@@ -9,8 +9,7 @@ import * as Heartbeat_Callback$ReventlessCore from "./Heartbeat_Callback.res.mjs
9
9
 
10
10
  function Make(Runner) {
11
11
  let construct = (_self, _name) => {};
12
- let connect = (runtime, remoteChannel, timeoutOpt, heartbeat) => {
13
- let timeout = timeoutOpt !== undefined ? timeoutOpt : 10;
12
+ let connect = (runtime, remoteChannel, timeout, heartbeat) => {
14
13
  let heartbeatResource = Component$ReventlessCore.toPulumiResource(heartbeat);
15
14
  let name = ComponentType$ReventlessCore.name(Stdlib_Option.getOr(heartbeatResource.__name, "Unnamed"), Heartbeat$ReventlessCore.componentType);
16
15
  let opts_parent = heartbeatResource;
@@ -24,8 +23,7 @@ function Make(Runner) {
24
23
  };
25
24
  Component$ReventlessCore.setOutputs(heartbeat, outputs);
26
25
  };
27
- let makeHandler = (id, timeoutOpt, publishToPluginExtensionPoint) => {
28
- let timeout = timeoutOpt !== undefined ? timeoutOpt : 10;
26
+ let makeHandler = (id, timeout, publishToPluginExtensionPoint) => {
29
27
  let Callback = Heartbeat_Callback$ReventlessCore.Make({
30
28
  publishToPluginExtensionPoint: publishToPluginExtensionPoint,
31
29
  id: id,
@@ -887,7 +887,11 @@ module Make = (
887
887
  | Some((_, pluginExtensionPointCommandTopicRemoteChannel)) =>
888
888
  // Notify platform hook that the EP channel is available (AWS extracts SQS URL for bundled heartbeat)
889
889
  Spec.hooks.onHeartbeatEpChannelAvailable->Option.forEach(hook =>
890
- hook(pluginExtensionPointCommandTopicRemoteChannel->Obj.magic, ~pluginId=id)
890
+ hook(
891
+ pluginExtensionPointCommandTopicRemoteChannel->Obj.magic,
892
+ ~pluginId=id,
893
+ ~heartbeatInterval,
894
+ )
891
895
  )
892
896
  heartbeat->PluginRuntimeBuilder.forPluginHeartbeat(
893
897
  ~handler,
@@ -545,7 +545,7 @@ function Make(Spec) {
545
545
  let handler = SpecificHeartbeat.makeHandler(id, heartbeatInterval, publishToPluginExtensionPoint);
546
546
  if (coreSetup !== undefined) {
547
547
  let pluginExtensionPointCommandTopicRemoteChannel$1 = coreSetup[1];
548
- Stdlib_Option.forEach(Spec.hooks.onHeartbeatEpChannelAvailable, hook => hook(pluginExtensionPointCommandTopicRemoteChannel$1, id));
548
+ Stdlib_Option.forEach(Spec.hooks.onHeartbeatEpChannelAvailable, hook => hook(pluginExtensionPointCommandTopicRemoteChannel$1, id, heartbeatInterval));
549
549
  PluginRuntimeBuilder.forPluginHeartbeat(handler, none => SpecificHeartbeat.connect(none, pluginExtensionPointCommandTopicRemoteChannel$1, heartbeatInterval, heartbeat), undefined, undefined, heartbeat);
550
550
  } else {
551
551
  PluginRuntimeBuilder.forPluginHeartbeat(handler, param => {}, undefined, undefined, heartbeat);
@@ -957,7 +957,12 @@ type platformHooks = {
957
957
  onDcbEventLogCreated?: unknown => unit,
958
958
  onDcbCommandTopicCreated?: unknown => unit,
959
959
  onDcbSlicesCreated?: unknown => unit,
960
- onHeartbeatEpChannelAvailable?: (unknown, ~pluginId: string) => unit,
960
+ // heartbeatInterval carries the plugin's heartbeat cadence so the AWS bundled
961
+ // heartbeat Lambda emits Heartbeat(interval) matching its EventBridge schedule
962
+ // rate. Both derive from the same heartbeatInterval; threading it here prevents
963
+ // the schedule-rate vs command-interval divergence that caused connect/disconnect
964
+ // flapping (schedule every 60min but Heartbeat(10) → 12min grace).
965
+ onHeartbeatEpChannelAvailable?: (unknown, ~pluginId: string, ~heartbeatInterval: int) => unit,
961
966
  // ── Admin → Plugin coordination ────────────────────────────────────────
962
967
  // Set by makePlatform after Admin.construct(); read by Plugin_Builder to
963
968
  // wire the local admin connection path (in-memory). Starts as an empty
@@ -5,6 +5,14 @@ open Reventless.Plugin
5
5
 
6
6
  module PluginExtensionPointSpec = ReventlessInfra.PluginExtensionPointSpec
7
7
 
8
+ // Grace before a plugin is marked Disconnected once heartbeats stop, expressed in
9
+ // whole minutes (schedules can only be created by minute). The margin scales with
10
+ // the interval so large cadences keep real headroom: a fixed +2 gave a 60-min
11
+ // heartbeat only ~3% slack, so a single late EventBridge beat + Lambda cold-start
12
+ // could trip a spurious disconnect. `+ max(2, interval / 2)` keeps ≥ +2 min for
13
+ // small intervals and ≥ +50% for large ones (5→7, 10→15, 60→90).
14
+ let disconnectGrace = interval => interval + max(2, interval / 2)
15
+
8
16
  module type Spec = {
9
17
  let runtimeOps: PluginRuntimeOperations.operations
10
18
  let environment: string
@@ -145,10 +153,9 @@ module Make = (Spec: Spec) => {
145
153
  switch cmd {
146
154
  | PluginExtensionPointSpec.Heartbeat(interval) => [
147
155
  PublishCommand(Plugin.name(id), Delegate.Heartbeat(Plugin.version(id))),
148
- // Re-create timeout (+2 minute to avoid toggling)
149
- // 1 minute because Schedules can only be created by minute
150
- // 1 additional minute to allow additional latency
151
- HandleDirective(directiveHandler, CreateDisconnectSchedule(id, interval + 2)),
156
+ // Re-arm the disconnect schedule with an interval-scaled grace so a
157
+ // single late beat doesn't trip a spurious disconnect (see disconnectGrace).
158
+ HandleDirective(directiveHandler, CreateDisconnectSchedule(id, disconnectGrace(interval))),
152
159
  ]
153
160
  | RedetectPlugin(interval) => [
154
161
  // Deploy-time re-detect: drive Redetect (not Heartbeat) so an already-connected
@@ -156,7 +163,7 @@ module Make = (Spec: Spec) => {
156
163
  // re-arm the disconnect schedule exactly like a heartbeat so liveness tracking
157
164
  // continues from the deploy moment.
158
165
  PublishCommand(Plugin.name(id), Delegate.Redetect(Plugin.version(id))),
159
- HandleDirective(directiveHandler, CreateDisconnectSchedule(id, interval + 2)),
166
+ HandleDirective(directiveHandler, CreateDisconnectSchedule(id, disconnectGrace(interval))),
160
167
  ]
161
168
  | ConnectPlugin(pluginDefinition) =>
162
169
  // Validate protocol versions declared by the connecting plugin.
@@ -3,6 +3,7 @@
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
5
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
6
+ import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
8
  import * as Effect from "effect/Effect";
8
9
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
@@ -17,6 +18,10 @@ import * as PluginsReadModelSpec$ReventlessCore from "../lifecycle/PluginsReadMo
17
18
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
18
19
  import * as PluginExtensionPointSpec$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/PluginExtensionPointSpec.res.mjs";
19
20
 
21
+ function disconnectGrace(interval) {
22
+ return interval + Primitive_int.max(2, interval / 2 | 0) | 0;
23
+ }
24
+
20
25
  function Make(Spec) {
21
26
  let forwardCommand = async (_id, command, extensionPointName, queryEngine) => {
22
27
  let jsons = await queryEngine.scan(PluginSpec$ReventlessCore.name, [
@@ -151,7 +156,7 @@ function Make(Spec) {
151
156
  _1: {
152
157
  TAG: "CreateDisconnectSchedule",
153
158
  _0: id,
154
- _1: cmd._0 + 2 | 0
159
+ _1: disconnectGrace(cmd._0)
155
160
  }
156
161
  }
157
162
  ];
@@ -201,7 +206,7 @@ function Make(Spec) {
201
206
  _1: {
202
207
  TAG: "CreateDisconnectSchedule",
203
208
  _0: id,
204
- _1: cmd._0 + 2 | 0
209
+ _1: disconnectGrace(cmd._0)
205
210
  }
206
211
  }
207
212
  ];
@@ -369,6 +374,7 @@ export {
369
374
  name,
370
375
  Id,
371
376
  PluginExtensionPointSpec,
377
+ disconnectGrace,
372
378
  Make,
373
379
  moduleUrl,
374
380
  }
@@ -0,0 +1,98 @@
1
+ // Regression tests for the plugin-lifecycle heartbeat / disconnect margin.
2
+ //
3
+ // A plugin's liveness is scheduler-driven: every heartbeat re-arms a one-time
4
+ // disconnect schedule, and if heartbeats stop the schedule fires and marks the
5
+ // plugin Disconnected. Two independently-wired values must agree — the recurring
6
+ // heartbeat *schedule rate* and the *interval carried inside the Heartbeat command*
7
+ // (which drives CreateDisconnectSchedule). A deployed plugin once ran with schedule
8
+ // rate(60min) but a Heartbeat(10) command (grace ~12min), so it reconnected for
9
+ // ~12min after each hourly beat then sat Disconnected for the rest of the hour —
10
+ // it flapped. See docs/plans/plugin-heartbeat-disconnect-margin-hardening.md.
11
+ //
12
+ // These tests pin the two guarantees that the fix provides at this layer:
13
+ // 1. the disconnect grace scales with the interval (large cadences keep headroom);
14
+ // 2. the disconnect schedule the EP mapping arms is derived from the *same*
15
+ // interval the Heartbeat/RedetectPlugin command carries — never a constant.
16
+ // (The schedule-rate ⇄ command-interval single-source is additionally enforced at
17
+ // compile time now that Heartbeat_Builder's ~timeout is mandatory.)
18
+
19
+ open JestGlobals
20
+
21
+ module PluginExtensionPointSpec = ReventlessInfra.PluginExtensionPointSpec
22
+ open ReventlessInfra.ExtensionPointMapping
23
+
24
+ // The mapping functor only needs runtimeOps at directive-*execution* time; the
25
+ // pure mapIncomingCommand under test never touches it, so a stub Spec suffices
26
+ // (mirrors the Obj.magic stubbing already used in HeartbeatTest.res).
27
+ module TestMapping = PluginExtensionPoint_Plugin.Make({
28
+ let runtimeOps = Obj.magic()
29
+ let environment = "test"
30
+ let updateApiSchema = None
31
+ let manageSubscriptions = None
32
+ })
33
+
34
+ // Pull the disconnect-schedule grace (in minutes) out of the actions a mapping
35
+ // emits for an incoming command, if any.
36
+ let graceFromActions = actions =>
37
+ actions->Array.findMap((action: commandAction<_, _>) =>
38
+ switch action {
39
+ | HandleDirective(_, PluginExtensionPointSpec.CreateDisconnectSchedule(_, timeout)) =>
40
+ Some(timeout)
41
+ | _ => None
42
+ }
43
+ )
44
+
45
+ let meta = Message.generateMeta(~service="test", ~user="test")
46
+
47
+ describe("disconnectGrace", () => {
48
+ // The observed-bug fix: the margin must scale, so a 60-min cadence keeps real
49
+ // headroom instead of the old fixed +2 (~3%).
50
+ testSync("scales with the interval (5→7, 10→15, 60→90)", () => {
51
+ expect(PluginExtensionPoint_Plugin.disconnectGrace(5))->toBe(7)
52
+ expect(PluginExtensionPoint_Plugin.disconnectGrace(10))->toBe(15)
53
+ expect(PluginExtensionPoint_Plugin.disconnectGrace(60))->toBe(90)
54
+ })
55
+
56
+ testSync("always leaves at least a full interval of headroom past one beat", () => {
57
+ // Grace must exceed the interval (else a single beat's cadence trips a
58
+ // disconnect) and keep the historical ≥ +2 min floor for small intervals.
59
+ [1, 2, 5, 10, 30, 60, 120]->Array.forEach(interval => {
60
+ let grace = PluginExtensionPoint_Plugin.disconnectGrace(interval)
61
+ expect(grace > interval)->toBe(true)
62
+ expect(grace >= interval + 2)->toBe(true)
63
+ })
64
+ })
65
+
66
+ testSync("a 60-min cadence gets far more than the old fixed +2 grace", () => {
67
+ // The exact regression: interval 60 previously armed a 62-min disconnect.
68
+ expect(PluginExtensionPoint_Plugin.disconnectGrace(60) > 62)->toBe(true)
69
+ })
70
+ })
71
+
72
+ describe("PluginExtensionPoint mapping arms the disconnect schedule from the beat interval", () => {
73
+ testSync("Heartbeat(interval) → CreateDisconnectSchedule(_, disconnectGrace(interval))", () => {
74
+ [5, 10, 60]->Array.forEach(interval => {
75
+ let actions = TestMapping.PluginMapping.mapIncomingCommand(
76
+ "Test@1",
77
+ PluginExtensionPointSpec.Heartbeat(interval),
78
+ meta,
79
+ )
80
+ expect(actions->graceFromActions)->toEqual(
81
+ Some(PluginExtensionPoint_Plugin.disconnectGrace(interval)),
82
+ )
83
+ })
84
+ })
85
+
86
+ testSync("RedetectPlugin(interval) arms the same interval-derived grace as a heartbeat", () => {
87
+ [5, 10, 60]->Array.forEach(interval => {
88
+ let actions = TestMapping.PluginMapping.mapIncomingCommand(
89
+ "Test@1",
90
+ PluginExtensionPointSpec.RedetectPlugin(interval),
91
+ meta,
92
+ )
93
+ expect(actions->graceFromActions)->toEqual(
94
+ Some(PluginExtensionPoint_Plugin.disconnectGrace(interval)),
95
+ )
96
+ })
97
+ })
98
+ })