@reventlessdev/reventless-core 3.0.0-alpha.170 → 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.
Files changed (83) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +14 -14
  3. package/src/components/ExtensionPoint/ExtensionPoint_Builder.res +5 -2
  4. package/src/components/ExtensionPoint/ExtensionPoint_Builder.res.mjs +3 -3
  5. package/src/components/Heartbeat/Heartbeat.res +2 -2
  6. package/src/components/Heartbeat/Heartbeat_Builder.res +2 -2
  7. package/src/components/Heartbeat/Heartbeat_Builder.res.mjs +2 -4
  8. package/src/components/Task/Task_Builder.res +6 -4
  9. package/src/components/Task/Task_Builder.res.mjs +4 -4
  10. package/src/plugin/component/Plugin_Builder.res +5 -1
  11. package/src/plugin/component/Plugin_Builder.res.mjs +1 -1
  12. package/src/plugin/component/Plugin_Helpers.res +6 -1
  13. package/src/plugin/connect/PluginExtensionPoint_Builder.res +2 -0
  14. package/src/plugin/connect/PluginExtensionPoint_Builder.res.mjs +3 -3
  15. package/src/plugin/connect/PluginExtensionPoint_Plugin.res +12 -5
  16. package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +8 -2
  17. package/tests/plugin/HeartbeatDisconnectGraceTest.res +98 -0
  18. package/tests/IdentityTest.res.mjs +0 -174
  19. package/tests/adapter/MonitoringTest.res.mjs +0 -82
  20. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +0 -356
  21. package/tests/admin/Platform_UIFragmentsApiTest.res.mjs +0 -101
  22. package/tests/aggregate/AggregateCacheTest.res.mjs +0 -433
  23. package/tests/aggregate/AggregateCallbackTest.res.mjs +0 -143
  24. package/tests/aggregate/AggregateCausationTest.res.mjs +0 -47
  25. package/tests/aggregate/AggregateConflictTest.res.mjs +0 -386
  26. package/tests/aggregate/AggregateFixtures.res.mjs +0 -308
  27. package/tests/aggregate/AggregateRejectionTest.res.mjs +0 -166
  28. package/tests/aggregate/AggregateSnapshotTest.res.mjs +0 -645
  29. package/tests/api/ApiNoApiTest.res.mjs +0 -90
  30. package/tests/api/GraphQL_StitcherTest.res.mjs +0 -112
  31. package/tests/api/SimpleTest.res.mjs +0 -10
  32. package/tests/api/SuryToJsonSchemaTest.res.mjs +0 -674
  33. package/tests/commandgenerator/CommandGeneratorCallbackTest.res.mjs +0 -160
  34. package/tests/commandgenerator/CommandGeneratorFixtures.res.mjs +0 -197
  35. package/tests/commandtopic/CommandTopicCallbackFixtures.res.mjs +0 -152
  36. package/tests/commandtopic/CommandTopicCallbackTest.res.mjs +0 -106
  37. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res.mjs +0 -109
  38. package/tests/counter/CounterCallbackTest.res.mjs +0 -140
  39. package/tests/counter/CounterFixtures.res.mjs +0 -67
  40. package/tests/dcb/DcbDecodeTest.res.mjs +0 -194
  41. package/tests/dcb/DcbEventLogOperationsTest.res.mjs +0 -268
  42. package/tests/dcb/DcbFixtures.res.mjs +0 -534
  43. package/tests/dcb/DcbScopeInferenceTest.res.mjs +0 -480
  44. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +0 -540
  45. package/tests/dcb/DcbTagTest.res.mjs +0 -867
  46. package/tests/dcb/DcbValidationTest.res.mjs +0 -476
  47. package/tests/eventlog/EventLogFixtures.res.mjs +0 -196
  48. package/tests/eventlog/EventLogOperationsTest.res.mjs +0 -192
  49. package/tests/eventlog/EventLogRetryTest.res.mjs +0 -195
  50. package/tests/eventmapper/EventMapperCallbackTest.res.mjs +0 -141
  51. package/tests/eventmapper/EventMapperFixtures.res.mjs +0 -440
  52. package/tests/eventmapper/EventMapperTest.res.mjs +0 -83
  53. package/tests/eventtopic/EventTopicFixtures.res.mjs +0 -65
  54. package/tests/eventtopic/EventTopicOperationsTest.res.mjs +0 -71
  55. package/tests/extensionpoint/ExtensionPointCallbackTest.res.mjs +0 -94
  56. package/tests/extensionpoint/ExtensionPointFixtures.res.mjs +0 -205
  57. package/tests/extensionpoint/ExtensionPointOperationsTest.res.mjs +0 -304
  58. package/tests/ftp/FTPTest.res.mjs +0 -88
  59. package/tests/logger/LogFormatTest.res.mjs +0 -309
  60. package/tests/logger/LoggerLazyTest.res.mjs +0 -40
  61. package/tests/message/MessageTest.res.mjs +0 -218
  62. package/tests/message/MetaEnvelopeTest.res.mjs +0 -188
  63. package/tests/plugin/ManifestVisibilityTest.res.mjs +0 -215
  64. package/tests/plugin/PluginSpecExperiment.res.mjs +0 -31
  65. package/tests/plugin/PluginStructureTest.res.mjs +0 -419
  66. package/tests/plugin/PluginVersionTest.res.mjs +0 -30
  67. package/tests/plugin/SchemaWalkerTest.res.mjs +0 -160
  68. package/tests/plugin/StateChangeSlice/PsPlaceOrder.res.mjs +0 -62
  69. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +0 -69
  70. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +0 -95
  71. package/tests/plugin/StateViewSlice/PsAvailableProductsView.res.mjs +0 -69
  72. package/tests/plugin/StateViewSlice/PsCustomersView.res.mjs +0 -69
  73. package/tests/plugin/StateViewSlice/PsOrdersView.res.mjs +0 -81
  74. package/tests/poc/StructuralSubtypingPoc.res.mjs +0 -61
  75. package/tests/projection/ProjectionOptimizeTest.res.mjs +0 -75
  76. package/tests/querydb/QueryDbFixtures.res.mjs +0 -135
  77. package/tests/querydb/QueryDbOperationsTest.res.mjs +0 -168
  78. package/tests/sideeffecthandler/SideEffectHandlerCallbackTest.res.mjs +0 -77
  79. package/tests/sideeffecthandler/SideEffectHandlerFixtures.res.mjs +0 -139
  80. package/tests/util/CommandPublisherTest.res.mjs +0 -78
  81. package/tests/util/CsvStreamTest.res.mjs +0 -51
  82. package/tests/util/LruTest.res.mjs +0 -105
  83. package/tests/util/MetricsTest.res.mjs +0 -53
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
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
+
13
+ # 3.0.0-alpha.171 (2026-07-17)
14
+
15
+ ### Features
16
+
17
+ * **runtime:** platform-supplied EP/task pod runtime floors ([37544d0](https://github.com/ReventlessDev/reventless-core/commit/37544d029d278bbcae6eeb0f583da07d6ce70f04))
18
+
19
+
6
20
  # 3.0.0-alpha.170 (2026-07-16)
7
21
 
8
22
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.170",
3
+ "version": "3.0.0-alpha.172",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,22 +28,22 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.28",
32
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.12",
33
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.13",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.8",
35
- "@reventlessdev/rescript-node-streams": "1.1.0-alpha.12",
36
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.16",
37
- "@reventlessdev/rescript-uuid": "1.1.0-alpha.16",
38
- "@reventlessdev/reventless-spec": "3.0.0-alpha.78",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.27",
40
- "@reventlessdev/reventless-infra": "3.0.0-alpha.101",
41
- "@reventlessdev/rescript-ssh2": "1.1.0-alpha.13"
31
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.29",
32
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
34
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
35
+ "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
36
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
37
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.102",
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
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.79"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.53"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.54"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -55,7 +55,7 @@
55
55
  "repository": {
56
56
  "type": "git",
57
57
  "url": "git+https://github.com/ReventlessDev/reventless-core.git",
58
- "directory": "reventless/reventless-core"
58
+ "directory": "reventless/core"
59
59
  },
60
60
  "scripts": {
61
61
  "build": "rescript build",
@@ -7,6 +7,7 @@ module Make = (
7
7
  EventTopicAdapter: EventTopic_Adapter.Publisher,
8
8
  ExtensionPointRuntimeBuilder: ExtensionPointRuntime_Builder.T
9
9
  with module CommandTopicChannel := CommandTopicChannel,
10
+ Defaults: ReventlessInfra.RuntimeDefaults.T,
10
11
  ): ExtensionPoint.T => {
11
12
  module Spec = Spec
12
13
 
@@ -92,8 +93,10 @@ module Make = (
92
93
  commandTopic->ExtensionPointRuntimeBuilder.forCommandTopic(
93
94
  ~handler,
94
95
  ~connect=SpecificCommandTopic.connect(commandTopic, ~resources, ...),
95
- ~memorySize=runtime->ReventlessInfra.RuntimeHints.resolveMemory(~default=1024),
96
- ~timeout=runtime->ReventlessInfra.RuntimeHints.resolveTimeout(~default=30),
96
+ ~memorySize=runtime->ReventlessInfra.RuntimeHints.resolveMemory(
97
+ ~default=Defaults.memorySize,
98
+ ),
99
+ ~timeout=runtime->ReventlessInfra.RuntimeHints.resolveTimeout(~default=Defaults.timeout),
97
100
  ~specModulePath=Spec.moduleUrl,
98
101
  ~mappingsModulePath=Mappings.moduleUrl,
99
102
  ~publishToAggregatesQueueUrls,
@@ -17,7 +17,7 @@ import * as ExtensionPoint_Callback$ReventlessCore from "./ExtensionPoint_Callba
17
17
  import * as ExtensionPoint_Operations$ReventlessCore from "./ExtensionPoint_Operations.res.mjs";
18
18
 
19
19
  function Make(Spec) {
20
- return Mappings => (RuntimeEnvironment => (CommandTopicChannel => (EventTopicAdapter => (ExtensionPointRuntimeBuilder => {
20
+ return Mappings => (RuntimeEnvironment => (CommandTopicChannel => (EventTopicAdapter => (ExtensionPointRuntimeBuilder => (Defaults => {
21
21
  let name = Spec.name;
22
22
  let commandSchema = Spec.commandSchema;
23
23
  let eventSchema = Spec.eventSchema;
@@ -59,7 +59,7 @@ function Make(Spec) {
59
59
  return Pulumi.output("");
60
60
  }
61
61
  });
62
- ExtensionPointRuntimeBuilder.forCommandTopic(handler, none => SpecificCommandTopic.connect(none, resources, commandTopic), RuntimeHints$ReventlessInfra.resolveMemory(runtime, 1024), RuntimeHints$ReventlessInfra.resolveTimeout(runtime, 30), Spec.moduleUrl, Mappings.moduleUrl, publishToAggregatesQueueUrls, commandTopic);
62
+ ExtensionPointRuntimeBuilder.forCommandTopic(handler, none => SpecificCommandTopic.connect(none, resources, commandTopic), RuntimeHints$ReventlessInfra.resolveMemory(runtime, Defaults.memorySize), RuntimeHints$ReventlessInfra.resolveTimeout(runtime, Defaults.timeout), Spec.moduleUrl, Mappings.moduleUrl, publishToAggregatesQueueUrls, commandTopic);
63
63
  let SpecificEventTopic = EventTopic_Builder$ReventlessCore.Make({
64
64
  Id: Id$Reventless.$$String,
65
65
  name: name,
@@ -106,7 +106,7 @@ function Make(Spec) {
106
106
  outputs: Component$ReventlessCore.outputs,
107
107
  operations: Component$ReventlessCore.operations
108
108
  };
109
- }))));
109
+ })))));
110
110
  }
111
111
 
112
112
  export {
@@ -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,
@@ -28,6 +28,7 @@ module Make = (
28
28
  and type runtimeParts = RuntimeEnvironment.parts
29
29
  and type context = TaskRuntimeBuilder.context,
30
30
  SpecificSideEffectHandler: SideEffectHandler.T,
31
+ Defaults: ReventlessInfra.RuntimeDefaults.T,
31
32
  ): Task.T => {
32
33
  module Spec = Spec
33
34
  type component = Task.component
@@ -50,10 +51,11 @@ module Make = (
50
51
  taskName,
51
52
  ) => {
52
53
  let opts = {Pulumi.ComponentResource.parent: self->Component.toPulumiResource}
53
- // Task bucket callbacks default to a large envelope (bulk import/export work);
54
- // a plugin.json `runtime` override raises memory above it and replaces timeout.
55
- let memorySize = ReventlessInfra.RuntimeHints.resolveMemory(runtime, ~default=4096)
56
- let timeout = ReventlessInfra.RuntimeHints.resolveTimeout(runtime, ~default=600)
54
+ // Task bucket callbacks default to the platform-supplied task-pod floor (a
55
+ // large envelope for bulk import/export work); a plugin.json `runtime`
56
+ // override raises memory above it and replaces timeout.
57
+ let memorySize = ReventlessInfra.RuntimeHints.resolveMemory(runtime, ~default=Defaults.memorySize)
58
+ let timeout = ReventlessInfra.RuntimeHints.resolveTimeout(runtime, ~default=Defaults.timeout)
57
59
  let allCommandTopics = allAggregates->Aggregate.allCommandTopics
58
60
 
59
61
  // Each adapter chooses what string the Task runtime should treat as the
@@ -20,14 +20,14 @@ function pascalCase(s) {
20
20
  }
21
21
 
22
22
  function Make(Spec) {
23
- return RuntimeEnvironment => (EventCollectorChannel => (EventCollectorRuntimeBuilder => (TaskRuntimeBuilder => (TaskBucket => (SpecificSideEffectHandler => {
23
+ return RuntimeEnvironment => (EventCollectorChannel => (EventCollectorRuntimeBuilder => (TaskRuntimeBuilder => (TaskBucket => (SpecificSideEffectHandler => (Defaults => {
24
24
  let make = (queryBucketName, scheduler, schedulerRoleUrn, publishToAggregates, queryEngine, resourceNaming, allAggregates, runtime, opts) => Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(Task$ReventlessCore.componentType), Spec.name, (extra, extra$1) => {
25
25
  let opts_parent = Component$ReventlessCore.toPulumiResource(extra);
26
26
  let opts = {
27
27
  parent: opts_parent
28
28
  };
29
- let memorySize = RuntimeHints$ReventlessInfra.resolveMemory(runtime, 4096);
30
- let timeout = RuntimeHints$ReventlessInfra.resolveTimeout(runtime, 600);
29
+ let memorySize = RuntimeHints$ReventlessInfra.resolveMemory(runtime, Defaults.memorySize);
30
+ let timeout = RuntimeHints$ReventlessInfra.resolveTimeout(runtime, Defaults.timeout);
31
31
  let allCommandTopics = Aggregate$ReventlessCore.allCommandTopics(allAggregates);
32
32
  let publishToAggregatesQueueUrls = Stdlib_Dict.mapValues(allAggregates, agg => Output$Pulumi.flatMap(agg.commandTopic, ct => {
33
33
  let r = ct.resources[0];
@@ -107,7 +107,7 @@ function Make(Spec) {
107
107
  make: make,
108
108
  outputs: Component$ReventlessCore.outputs
109
109
  };
110
- })))));
110
+ }))))));
111
111
  }
112
112
 
113
113
  export {
@@ -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
@@ -17,6 +17,7 @@ module Make = (
17
17
  EventTopicAdapter: EventTopic_Adapter.Publisher,
18
18
  ExtensionPointRuntimeBuilder: ExtensionPointRuntime_Builder.T
19
19
  with module CommandTopicChannel := CommandTopicChannel,
20
+ Defaults: ReventlessInfra.RuntimeDefaults.T,
20
21
  ): ExtensionPoint.T => {
21
22
  module PluginMappingInstance = PluginExtensionPoint_Plugin.Make(Spec)
22
23
 
@@ -41,5 +42,6 @@ module Make = (
41
42
  CommandTopicChannel,
42
43
  EventTopicAdapter,
43
44
  ExtensionPointRuntimeBuilder,
45
+ Defaults,
44
46
  )
45
47
  }
@@ -6,7 +6,7 @@ import * as PluginExtensionPoint_Plugin$ReventlessCore from "./PluginExtensionPo
6
6
  import * as PluginExtensionPoint_UiFragment$ReventlessCore from "./PluginExtensionPoint_UiFragment.res.mjs";
7
7
 
8
8
  function Make(Spec) {
9
- return RuntimeEnvironment => (CommandTopicChannel => (EventTopicAdapter => (ExtensionPointRuntimeBuilder => {
9
+ return RuntimeEnvironment => (CommandTopicChannel => (EventTopicAdapter => (ExtensionPointRuntimeBuilder => (Defaults => {
10
10
  let PluginMappingInstance = PluginExtensionPoint_Plugin$ReventlessCore.Make(Spec);
11
11
  let name = PluginMappingInstance.Mapping.delegateName;
12
12
  let mappings = [
@@ -24,8 +24,8 @@ function Make(Spec) {
24
24
  commandSchema: PluginExtensionPointSpec$ReventlessInfra.commandSchema,
25
25
  eventSchema: PluginExtensionPointSpec$ReventlessInfra.eventSchema,
26
26
  directiveSchema: PluginExtensionPointSpec$ReventlessInfra.directiveSchema
27
- })(Mappings)(RuntimeEnvironment)(CommandTopicChannel)(EventTopicAdapter)(ExtensionPointRuntimeBuilder);
28
- })));
27
+ })(Mappings)(RuntimeEnvironment)(CommandTopicChannel)(EventTopicAdapter)(ExtensionPointRuntimeBuilder)(Defaults);
28
+ }))));
29
29
  }
30
30
 
31
31
  let PluginExtensionPointSpec;
@@ -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
+ })