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

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 (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +6 -6
  3. package/src/RequestContext.res +28 -0
  4. package/src/RequestContext.res.mjs +17 -5
  5. package/src/adapter/Runtime/AggregateRuntime_Builder_Common.res +8 -13
  6. package/src/adapter/Runtime/AggregateRuntime_Builder_Common.res.mjs +6 -9
  7. package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res +25 -5
  8. package/src/adapter/Runtime/AggregateRuntime_Builder_Micro.res.mjs +5 -3
  9. package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res +7 -1
  10. package/src/adapter/Runtime/EventCollectorRuntime_Builder_PerEventCollector.res.mjs +1 -1
  11. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +5 -10
  12. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +3 -3
  13. package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res +9 -2
  14. package/src/adapter/Runtime/ExtensionPointRuntime_Builder_PerExtensionPoint.res.mjs +2 -1
  15. package/src/adapter/Runtime/PluginRuntime_Builder_Micro.res +16 -3
  16. package/src/adapter/Runtime/PluginRuntime_Builder_Micro.res.mjs +3 -2
  17. package/src/adapter/Runtime/PluginRuntime_Builder_Single.res +16 -3
  18. package/src/adapter/Runtime/PluginRuntime_Builder_Single.res.mjs +3 -2
  19. package/src/adapter/Runtime/Runtime.res +63 -9
  20. package/src/adapter/Runtime/Runtime.res.mjs +25 -2
  21. package/src/components/Heartbeat/Heartbeat.res +2 -2
  22. package/src/components/Heartbeat/Heartbeat_Builder.res +2 -2
  23. package/src/components/Heartbeat/Heartbeat_Builder.res.mjs +2 -4
  24. package/src/plugin/component/Plugin_Builder.res +5 -1
  25. package/src/plugin/component/Plugin_Builder.res.mjs +1 -1
  26. package/src/plugin/component/Plugin_Helpers.res +6 -1
  27. package/src/plugin/connect/PluginExtensionPoint_Plugin.res +12 -5
  28. package/src/plugin/connect/PluginExtensionPoint_Plugin.res.mjs +8 -2
  29. package/tests/plugin/HeartbeatDisconnectGraceTest.res +98 -0
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.173 (2026-07-20)
7
+
8
+ ### Features
9
+
10
+ * **core:** annotate comp + causationId at the dispatch boundary ([ef6d56d](https://github.com/ReventlessDev/reventless-core/commit/ef6d56d0104dbdb21b7cfbb8f3b2b1ead9787595))
11
+
12
+
13
+ # 3.0.0-alpha.172 (2026-07-17)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **plugin:** scale heartbeat disconnect grace and enforce single-source interval ([7ba8fb7](https://github.com/ReventlessDev/reventless-core/commit/7ba8fb7bfbe023aba7b538329d2489f939b7fcea))
18
+
19
+
6
20
  # 3.0.0-alpha.171 (2026-07-17)
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.171",
3
+ "version": "3.0.0-alpha.173",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -30,15 +30,15 @@
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
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
33
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
34
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
+ "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
38
- "@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",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.79"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.102",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.79",
41
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.28"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -14,14 +14,42 @@
14
14
  // In tests:
15
15
  // ->Effect.provideService(RequestContext.tag, RequestContext.test())
16
16
 
17
+ // `causationId` = the direct-parent msgId of the message that triggered this
18
+ // invocation (Message.deriveMeta propagates it); reconstructs the parent → child
19
+ // causal chain within one `correlationId`. `component` / `pluginName` identify the
20
+ // component and plugin handling the invocation — mirror the `comp` / `plugin` log
21
+ // fields so a consumer of this service sees the same attribution the logs carry.
22
+ // All three are optional: absent in `.test()` and wherever the dispatch site has
23
+ // no value to supply.
17
24
  type t = {
18
25
  correlationId: string,
26
+ causationId?: string,
27
+ component?: string,
28
+ pluginName?: string,
19
29
  identity: Reventless.Identity.t,
20
30
  claims: dict<string>,
21
31
  }
22
32
 
23
33
  let tag: Context.tag<t> = Context.genericTag("reventless/RequestContext")
24
34
 
35
+ // Real dispatch-boundary constructor. Use this at runtime dispatch points
36
+ // (see Runtime.runEffect); `test` is test-only.
37
+ let make = (
38
+ ~correlationId,
39
+ ~causationId=?,
40
+ ~component=?,
41
+ ~pluginName=?,
42
+ ~identity=Reventless.Identity.anonymous,
43
+ ~claims=Dict.make(),
44
+ ): t => {
45
+ correlationId,
46
+ causationId: ?causationId,
47
+ component: ?component,
48
+ pluginName: ?pluginName,
49
+ identity,
50
+ claims,
51
+ }
52
+
25
53
  let getClaim = (ctx: t, key: string): option<string> =>
26
54
  ctx.claims->Dict.get(key)
27
55
 
@@ -5,6 +5,19 @@ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/I
5
5
 
6
6
  let tag = Context.GenericTag("reventless/RequestContext");
7
7
 
8
+ function make(correlationId, causationId, component, pluginName, identityOpt, claimsOpt) {
9
+ let identity = identityOpt !== undefined ? identityOpt : Identity$Reventless.anonymous;
10
+ let claims = claimsOpt !== undefined ? claimsOpt : ({});
11
+ return {
12
+ correlationId: correlationId,
13
+ causationId: causationId,
14
+ component: component,
15
+ pluginName: pluginName,
16
+ identity: identity,
17
+ claims: claims
18
+ };
19
+ }
20
+
8
21
  function getClaim(ctx, key) {
9
22
  return ctx.claims[key];
10
23
  }
@@ -12,11 +25,9 @@ function getClaim(ctx, key) {
12
25
  function withClaim(ctx, key, value) {
13
26
  let next = Object.fromEntries(Object.entries(ctx.claims));
14
27
  next[key] = value;
15
- return {
16
- correlationId: ctx.correlationId,
17
- identity: ctx.identity,
18
- claims: next
19
- };
28
+ let newrecord = {...ctx};
29
+ newrecord.claims = next;
30
+ return newrecord;
20
31
  }
21
32
 
22
33
  function test(correlationIdOpt, identityOpt, claimsOpt) {
@@ -32,6 +43,7 @@ function test(correlationIdOpt, identityOpt, claimsOpt) {
32
43
 
33
44
  export {
34
45
  tag,
46
+ make,
35
47
  getClaim,
36
48
  withClaim,
37
49
  test,
@@ -31,26 +31,21 @@ module Make = (
31
31
  let commandTopicHandlers: dict<effectHandler> = Dict.make()
32
32
  let eventCollectorHandlers: dict<array<effectHandler>> = Dict.make()
33
33
 
34
- let runEffect = (~correlationId=?, effect) => {
35
- let cid = correlationId->Option.getOr("unknown")
36
- effect
37
- // Annotate the request scope so every EffectLogger call inside the handler
38
- // carries correlationId as a top-level JSON field (decoded in install()).
39
- ->Effect.annotateLogs("correlationId", cid)
40
- ->Effect.provideService(RequestContext.tag, RequestContext.test(~correlationId=cid))
41
- ->Effect.runPromise
42
- }
43
-
44
34
  let aggregateHandler = aggregateName =>
45
35
  async (event: RuntimeEnvironment.event, context) => {
46
36
  let correlationId = event->RuntimeEnvironment.extractCorrelationId
37
+ let causationId = event->RuntimeEnvironment.extractCausationId
47
38
  let comp = `AggregateRuntime(${aggregateName})`
39
+ // Shared dispatch helper (Runtime.runEffect) annotates correlationId / comp /
40
+ // causationId onto the invocation so the application handler's own log lines
41
+ // carry them without re-logging by hand.
42
+ let runEffect = effect => Runtime.runEffect(~correlationId?, ~causationId?, ~comp, effect)
48
43
  switch event->CommandGenerator.metaInfo {
49
44
  | Some(info) =>
50
45
  switch commandGeneratorHandlers->Dict.get(info) {
51
46
  | Some(handler) =>
52
47
  EffectLogger.logDebug(~comp, `found CommandGenerator handler for ${info}`)->Effect.runSync
53
- let _ = await runEffect(~correlationId?, handler(event->CommandGenerator.asPayload, context))
48
+ let _ = await runEffect(handler(event->CommandGenerator.asPayload, context))
54
49
  | None =>
55
50
  let available = commandGeneratorHandlers->Dict.keysToArray->Array.join(",")
56
51
  EffectLogger.logWarn(
@@ -66,7 +61,7 @@ module Make = (
66
61
  switch commandTopicHandlers->Dict.get(urn) {
67
62
  | Some(handler) =>
68
63
  EffectLogger.logDebug(~comp, `found CommandTopic handler for ${urn}`)->Effect.runSync
69
- await runEffect(~correlationId?, handler(event, context))
64
+ await runEffect(handler(event, context))
70
65
  | None =>
71
66
  switch eventCollectorHandlers->Dict.get(urn) {
72
67
  | Some(handlers) =>
@@ -76,7 +71,7 @@ module Make = (
76
71
  `found ${count} EventCollector handler(s) for ${urn}`,
77
72
  )->Effect.runSync
78
73
  let _ = await handlers
79
- ->Array.map(handler => runEffect(~correlationId?, handler(event, context)))
74
+ ->Array.map(handler => runEffect(handler(event, context)))
80
75
  ->Promise.all
81
76
  | None =>
82
77
  let available =
@@ -6,9 +6,9 @@ import * as Effect from "effect/Effect";
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
8
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
9
+ import * as Runtime$ReventlessCore from "./Runtime.res.mjs";
9
10
  import * as Component$ReventlessCore from "../../components/Component.res.mjs";
10
11
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
11
- import * as RequestContext$ReventlessCore from "../../RequestContext.res.mjs";
12
12
  import * as CommandGenerator$ReventlessCore from "../../components/CommandGenerator/CommandGenerator.res.mjs";
13
13
 
14
14
  function Make(RuntimeEnvironment) {
@@ -18,19 +18,17 @@ function Make(RuntimeEnvironment) {
18
18
  let commandGeneratorHandlers = {};
19
19
  let commandTopicHandlers = {};
20
20
  let eventCollectorHandlers = {};
21
- let runEffect = (correlationId, effect) => {
22
- let cid = Stdlib_Option.getOr(correlationId, "unknown");
23
- return Effect.runPromise(Effect.provideService(Effect.annotateLogs(effect, "correlationId", cid), RequestContext$ReventlessCore.tag, RequestContext$ReventlessCore.test(cid, undefined, undefined)));
24
- };
25
21
  let aggregateHandler = aggregateName => (async (event, context) => {
26
22
  let correlationId = RuntimeEnvironment.extractCorrelationId(event);
23
+ let causationId = RuntimeEnvironment.extractCausationId(event);
27
24
  let comp = `AggregateRuntime(` + aggregateName + `)`;
25
+ let runEffect = effect => Runtime$ReventlessCore.runEffect(correlationId, causationId, comp, undefined, effect);
28
26
  let info = CommandGenerator$ReventlessCore.metaInfo(event);
29
27
  if (info !== undefined) {
30
28
  let handler = commandGeneratorHandlers[info];
31
29
  if (handler !== undefined) {
32
30
  Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `found CommandGenerator handler for ` + info));
33
- await runEffect(correlationId, handler(event, context));
31
+ await runEffect(handler(event, context));
34
32
  return;
35
33
  }
36
34
  let available = Object.keys(commandGeneratorHandlers).join(",");
@@ -42,13 +40,13 @@ function Make(RuntimeEnvironment) {
42
40
  let handler = commandTopicHandlers[urn];
43
41
  if (handler !== undefined) {
44
42
  Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `found CommandTopic handler for ` + urn));
45
- return await runEffect(correlationId, handler(event, context));
43
+ return await runEffect(handler(event, context));
46
44
  }
47
45
  let handlers = eventCollectorHandlers[urn];
48
46
  if (handlers !== undefined) {
49
47
  let count = handlers.length.toString();
50
48
  Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `found ` + count + ` EventCollector handler(s) for ` + urn));
51
- await Promise.all(handlers.map(handler => runEffect(correlationId, handler(event, context))));
49
+ await Promise.all(handlers.map(handler => runEffect(handler(event, context))));
52
50
  return;
53
51
  }
54
52
  let available = Object.keys(commandTopicHandlers).concat(Object.keys(eventCollectorHandlers)).join(",");
@@ -171,7 +169,6 @@ function Make(RuntimeEnvironment) {
171
169
  commandGeneratorHandlers: commandGeneratorHandlers,
172
170
  commandTopicHandlers: commandTopicHandlers,
173
171
  eventCollectorHandlers: eventCollectorHandlers,
174
- runEffect: runEffect,
175
172
  aggregateHandler: aggregateHandler,
176
173
  getRuntimeSpec: getRuntimeSpec,
177
174
  setRuntimeSpec: setRuntimeSpec,
@@ -23,10 +23,17 @@ module Make = (
23
23
  commandGenerator,
24
24
  ) => {
25
25
  let resource = commandGenerator->Component.toPulumiResource
26
+ let name = resource.name->ComponentType.nameOpt(CommandGenerator.componentType)
26
27
  let runtime = RuntimeEnvironment.make(
27
- ~name=resource.name->ComponentType.nameOpt(CommandGenerator.componentType),
28
+ ~name,
28
29
  ~handler=handler->Pulumi.Output.apply(handler =>
29
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
30
+ handler
31
+ ->RuntimeEnvironment.asEffectHandler
32
+ ->Runtime.runEffectHandler(
33
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
34
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
35
+ ~comp=name,
36
+ )
30
37
  ),
31
38
  ~memorySize,
32
39
  ~timeout,
@@ -44,10 +51,17 @@ module Make = (
44
51
  commandTopic,
45
52
  ) => {
46
53
  let resource = commandTopic->Component.toPulumiResource
54
+ let name = resource.name->ComponentType.nameOpt(CommandTopic.componentType)
47
55
  let runtime = RuntimeEnvironment.make(
48
- ~name=resource.name->ComponentType.nameOpt(CommandTopic.componentType),
56
+ ~name,
49
57
  ~handler=handler->Pulumi.Output.apply(handler =>
50
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
58
+ handler
59
+ ->RuntimeEnvironment.asEffectHandler
60
+ ->Runtime.runEffectHandler(
61
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
62
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
63
+ ~comp=name,
64
+ )
51
65
  ),
52
66
  ~memorySize,
53
67
  ~timeout,
@@ -72,7 +86,13 @@ module Make = (
72
86
  let runtime = RuntimeEnvironment.make(
73
87
  ~name,
74
88
  ~handler=handler->Pulumi.Output.apply(handler =>
75
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
89
+ handler
90
+ ->RuntimeEnvironment.asEffectHandler
91
+ ->Runtime.runEffectHandler(
92
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
93
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
94
+ ~comp=name,
95
+ )
76
96
  ),
77
97
  ~memorySize,
78
98
  ~timeout,
@@ -13,7 +13,8 @@ function Make(RuntimeEnvironment) {
13
13
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
14
14
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
15
15
  let resource = Component$ReventlessCore.toPulumiResource(commandGenerator);
16
- return connect(RuntimeEnvironment.make(ComponentType$ReventlessCore.nameOpt(resource.__name, CommandGenerator$ReventlessCore.componentType), handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
16
+ let name = ComponentType$ReventlessCore.nameOpt(resource.__name, CommandGenerator$ReventlessCore.componentType);
17
+ return connect(RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
17
18
  parent: resource
18
19
  }));
19
20
  };
@@ -21,7 +22,8 @@ function Make(RuntimeEnvironment) {
21
22
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
22
23
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
23
24
  let resource = Component$ReventlessCore.toPulumiResource(commandTopic);
24
- return connect(RuntimeEnvironment.make(ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType), handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
25
+ let name = ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType);
26
+ return connect(RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
25
27
  parent: resource
26
28
  }));
27
29
  };
@@ -34,7 +36,7 @@ function Make(RuntimeEnvironment) {
34
36
  let opts = {
35
37
  parent: opts_parent
36
38
  };
37
- let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, opts);
39
+ let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, opts);
38
40
  EventCollectorChannel.connect(name, [{
39
41
  channel: eventCollector.channel,
40
42
  eventTopics: eventTopics,
@@ -26,7 +26,13 @@ module Make = (
26
26
  let runtime = RuntimeEnvironment.make(
27
27
  ~name,
28
28
  ~handler=handler->Pulumi.Output.apply(handler =>
29
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
29
+ handler
30
+ ->RuntimeEnvironment.asEffectHandler
31
+ ->Runtime.runEffectHandler(
32
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
33
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
34
+ ~comp=name,
35
+ )
30
36
  ),
31
37
  ~memorySize,
32
38
  ~timeout,
@@ -16,7 +16,7 @@ function Make(RuntimeEnvironment) {
16
16
  let opts = {
17
17
  parent: opts_parent
18
18
  };
19
- let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
19
+ let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
20
20
  parent: resource
21
21
  });
22
22
  EventCollectorChannel.connect(name, [{
@@ -35,20 +35,15 @@ module Make = (
35
35
  })
36
36
  type effectHandler = Runtime.effectHandler<RuntimeEnvironment.event, context, unit, string>
37
37
  let eventCollectorHandlers: dict<array<effectHandler>> = Dict.make()
38
- let runEffect = (~correlationId=?, effect) => {
39
- let cid = correlationId->Option.getOr("unknown")
40
- effect
41
- // Annotate the request scope so every EffectLogger call inside the handler
42
- // carries correlationId as a top-level JSON field (decoded in install()).
43
- ->Effect.annotateLogs("correlationId", cid)
44
- ->Effect.provideService(RequestContext.tag, RequestContext.test(~correlationId=cid))
45
- ->Effect.runPromise
46
- }
47
38
 
48
39
  let eventCollectorHandler = parentName =>
49
40
  async (event: RuntimeEnvironment.event, context) => {
50
41
  let correlationId = event->RuntimeEnvironment.extractCorrelationId
42
+ let causationId = event->RuntimeEnvironment.extractCausationId
51
43
  let comp = `EventCollectorRuntime(${parentName})`
44
+ // Shared dispatch helper annotates correlationId / comp / causationId so the
45
+ // application handler's own log lines carry them (see Runtime.runEffect).
46
+ let runEffect = effect => Runtime.runEffect(~correlationId?, ~causationId?, ~comp, effect)
52
47
  let _ = await event
53
48
  ->RuntimeEnvironment.groupBySource
54
49
  ->Dict.toArray
@@ -61,7 +56,7 @@ module Make = (
61
56
  `found ${count} handler(s) for ${urn}`,
62
57
  )->Effect.runSync
63
58
  let _ = await handlers
64
- ->Array.map(handler => runEffect(~correlationId?, handler(event, context)))
59
+ ->Array.map(handler => runEffect(handler(event, context)))
65
60
  ->Promise.all
66
61
  | None => EffectLogger.logWarn(~comp, `no handler found: ${urn}`)->Effect.runSync
67
62
  }
@@ -6,9 +6,9 @@ import * as Pulumi from "@pulumi/pulumi";
6
6
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
7
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
8
8
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
9
+ import * as Runtime$ReventlessCore from "./Runtime.res.mjs";
9
10
  import * as Component$ReventlessCore from "../../components/Component.res.mjs";
10
11
  import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
11
- import * as RequestContext$ReventlessCore from "../../RequestContext.res.mjs";
12
12
 
13
13
  function Make(RuntimeEnvironment) {
14
14
  return EventCollectorChannel => {
@@ -29,6 +29,7 @@ function Make(RuntimeEnvironment) {
29
29
  let eventCollectorHandlers = {};
30
30
  let eventCollectorHandler = parentName => (async (event, context) => {
31
31
  let correlationId = RuntimeEnvironment.extractCorrelationId(event);
32
+ let causationId = RuntimeEnvironment.extractCausationId(event);
32
33
  let comp = `EventCollectorRuntime(` + parentName + `)`;
33
34
  await Promise.all(Object.entries(RuntimeEnvironment.groupBySource(event)).map(async param => {
34
35
  let event = param[1];
@@ -41,8 +42,7 @@ function Make(RuntimeEnvironment) {
41
42
  Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `found ` + count + ` handler(s) for ` + urn));
42
43
  await Promise.all(handlers.map(handler => {
43
44
  let effect = handler(event, context);
44
- let cid = Stdlib_Option.getOr(correlationId, "unknown");
45
- return Effect.runPromise(Effect.provideService(Effect.annotateLogs(effect, "correlationId", cid), RequestContext$ReventlessCore.tag, RequestContext$ReventlessCore.test(cid, undefined, undefined)));
45
+ return Runtime$ReventlessCore.runEffect(correlationId, causationId, comp, undefined, effect);
46
46
  }));
47
47
  }));
48
48
  });
@@ -22,10 +22,17 @@ module Make = (
22
22
  commandTopic,
23
23
  ) => {
24
24
  let resource = commandTopic->Component.toPulumiResource
25
+ let name = resource.name->ComponentType.nameOpt(CommandTopic.componentType)
25
26
  let runtime = RuntimeEnvironment.make(
26
- ~name=resource.name->ComponentType.nameOpt(CommandTopic.componentType),
27
+ ~name,
27
28
  ~handler=handler->Pulumi.Output.apply(handler =>
28
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
29
+ handler
30
+ ->RuntimeEnvironment.asEffectHandler
31
+ ->Runtime.runEffectHandler(
32
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
33
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
34
+ ~comp=name,
35
+ )
29
36
  ),
30
37
  ~memorySize,
31
38
  ~timeout,
@@ -11,7 +11,8 @@ function Make(RuntimeEnvironment) {
11
11
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
12
12
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
13
13
  let resource = Component$ReventlessCore.toPulumiResource(commandTopic);
14
- return connect(RuntimeEnvironment.make(ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType), handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
14
+ let name = ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType);
15
+ return connect(RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
15
16
  parent: resource
16
17
  }));
17
18
  };
@@ -26,7 +26,13 @@ module Make = (
26
26
  let runtime = RuntimeEnvironment.make(
27
27
  ~name,
28
28
  ~handler=handler->Pulumi.Output.apply(handler =>
29
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
29
+ handler
30
+ ->RuntimeEnvironment.asEffectHandler
31
+ ->Runtime.runEffectHandler(
32
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
33
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
34
+ ~comp=name,
35
+ )
30
36
  ),
31
37
  ~memorySize,
32
38
  ~timeout,
@@ -70,10 +76,17 @@ module Make = (
70
76
  dcbCommandTopic,
71
77
  ) => {
72
78
  let resource = dcbCommandTopic->Component.toPulumiResource
79
+ let name = resource.name->ComponentType.nameOpt(CommandTopic.componentType)
73
80
  let runtime = RuntimeEnvironment.make(
74
- ~name=resource.name->ComponentType.nameOpt(CommandTopic.componentType),
81
+ ~name,
75
82
  ~handler=handler->Pulumi.Output.apply(handler =>
76
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
83
+ handler
84
+ ->RuntimeEnvironment.asEffectHandler
85
+ ->Runtime.runEffectHandler(
86
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
87
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
88
+ ~comp=name,
89
+ )
77
90
  ),
78
91
  ~memorySize,
79
92
  ~timeout,
@@ -18,7 +18,7 @@ function Make(RuntimeEnvironment) {
18
18
  let opts = {
19
19
  parent: opts_parent
20
20
  };
21
- let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
21
+ let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
22
22
  parent: resource
23
23
  });
24
24
  EventCollectorChannel.connect(name, [{
@@ -40,7 +40,8 @@ function Make(RuntimeEnvironment) {
40
40
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
41
41
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
42
42
  let resource = Component$ReventlessCore.toPulumiResource(dcbCommandTopic);
43
- return connect(RuntimeEnvironment.make(ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType), handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
43
+ let name = ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType);
44
+ return connect(RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
44
45
  parent: resource
45
46
  }));
46
47
  };
@@ -26,7 +26,13 @@ module Make = (
26
26
  let runtime = RuntimeEnvironment.make(
27
27
  ~name,
28
28
  ~handler=handler->Pulumi.Output.apply(handler =>
29
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
29
+ handler
30
+ ->RuntimeEnvironment.asEffectHandler
31
+ ->Runtime.runEffectHandler(
32
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
33
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
34
+ ~comp=name,
35
+ )
30
36
  ),
31
37
  ~memorySize,
32
38
  ~timeout,
@@ -70,10 +76,17 @@ module Make = (
70
76
  dcbCommandTopic,
71
77
  ) => {
72
78
  let resource = dcbCommandTopic->Component.toPulumiResource
79
+ let name = resource.name->ComponentType.nameOpt(CommandTopic.componentType)
73
80
  let runtime = RuntimeEnvironment.make(
74
- ~name=resource.name->ComponentType.nameOpt(CommandTopic.componentType),
81
+ ~name,
75
82
  ~handler=handler->Pulumi.Output.apply(handler =>
76
- handler->RuntimeEnvironment.asEffectHandler->Runtime.runEffectHandler
83
+ handler
84
+ ->RuntimeEnvironment.asEffectHandler
85
+ ->Runtime.runEffectHandler(
86
+ ~extractCorrelationId=RuntimeEnvironment.extractCorrelationId,
87
+ ~extractCausationId=RuntimeEnvironment.extractCausationId,
88
+ ~comp=name,
89
+ )
77
90
  ),
78
91
  ~memorySize,
79
92
  ~timeout,
@@ -18,7 +18,7 @@ function Make(RuntimeEnvironment) {
18
18
  let opts = {
19
19
  parent: opts_parent
20
20
  };
21
- let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
21
+ let runtime = RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
22
22
  parent: resource
23
23
  });
24
24
  EventCollectorChannel.connect(name, [{
@@ -40,7 +40,8 @@ function Make(RuntimeEnvironment) {
40
40
  let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 1024;
41
41
  let timeout = timeoutOpt !== undefined ? timeoutOpt : 30;
42
42
  let resource = Component$ReventlessCore.toPulumiResource(dcbCommandTopic);
43
- return connect(RuntimeEnvironment.make(ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType), handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
43
+ let name = ComponentType$ReventlessCore.nameOpt(resource.__name, CommandTopic$ReventlessCore.componentType);
44
+ return connect(RuntimeEnvironment.make(name, handler.apply(handler => Runtime$ReventlessCore.runEffectHandler(RuntimeEnvironment.extractCorrelationId, RuntimeEnvironment.extractCausationId, name, undefined, RuntimeEnvironment.asEffectHandler(handler))), memorySize, timeout, {
44
45
  parent: resource
45
46
  }));
46
47
  };
@@ -4,17 +4,70 @@ type effectHandler<'event, 'context, 'result, 'error> = (
4
4
  'context,
5
5
  ) => Effect.t<'result, 'error, unit>
6
6
 
7
- // Convert an effectHandler to an eventHandler by providing Logger, RequestContext, and running as promise.
8
- // Used at handler dispatch points (runtime builders) where Effect.runPromise is called.
9
- let runEffectHandler = (handler: effectHandler<'event, 'context, 'result, 'error>): eventHandler<
10
- 'event,
11
- 'context,
12
- 'result,
13
- > =>
14
- (event, ctx) =>
7
+ // ─── Dispatch-boundary annotation ────────────────────────────────────────────
8
+ // Single source of truth for what every handler invocation carries. Annotating
9
+ // here (rather than in each runtime builder) guarantees that every log line
10
+ // inside the handler — framework *and* application — surfaces the same
11
+ // `correlationId` / `comp` / `causationId` top-level JSON fields (EffectLogger
12
+ // decodes them), and that a populated RequestContext is provided. `comp` is what
13
+ // makes two components hosted in one runtime process separable purely by field;
14
+ // `causationId` reconstructs the parent → child chain within one `correlationId`.
15
+ let annotateInvocation = (
16
+ effect,
17
+ ~correlationId=?,
18
+ ~causationId=?,
19
+ ~comp=?,
20
+ ~pluginName=?,
21
+ ) => {
22
+ let cid = correlationId->Option.getOr("unknown")
23
+ let annotateOpt = (eff, key, value) =>
24
+ switch value {
25
+ | Some(v) => eff->Effect.annotateLogs(key, v)
26
+ | None => eff
27
+ }
28
+ effect
29
+ ->Effect.annotateLogs("correlationId", cid)
30
+ ->annotateOpt("comp", comp)
31
+ ->annotateOpt("causationId", causationId)
32
+ ->Effect.provideService(
33
+ RequestContext.tag,
34
+ RequestContext.make(~correlationId=cid, ~causationId?, ~component=?comp, ~pluginName?),
35
+ )
36
+ }
37
+
38
+ // Annotate + run. Used by the multi-component dispatchers (AllAggregates,
39
+ // AllEventCollectors) that loop over per-source handlers.
40
+ let runEffect = (~correlationId=?, ~causationId=?, ~comp=?, ~pluginName=?, effect) =>
41
+ effect
42
+ ->annotateInvocation(~correlationId?, ~causationId?, ~comp?, ~pluginName?)
43
+ ->Effect.runPromise
44
+
45
+ // Convert an effectHandler to an eventHandler at build time, annotating the
46
+ // invocation on each call. Used by the single-component-per-Lambda strategies
47
+ // (Micro / PerAggregate / Plugin / PerEventCollector / PerExtensionPoint), which
48
+ // pass the Environment's `extractCorrelationId` / `extractCausationId` and the
49
+ // component `comp` so their application handlers get the same fields as the
50
+ // multi-component path. Extractors default to none for generic use.
51
+ let runEffectHandler = (
52
+ ~extractCorrelationId: option<'event => option<string>>=?,
53
+ ~extractCausationId: option<'event => option<string>>=?,
54
+ ~comp=?,
55
+ ~pluginName=?,
56
+ handler: effectHandler<'event, 'context, 'result, 'error>,
57
+ ): eventHandler<'event, 'context, 'result> =>
58
+ (event, ctx) => {
59
+ let correlationId = switch extractCorrelationId {
60
+ | Some(f) => f(event)
61
+ | None => None
62
+ }
63
+ let causationId = switch extractCausationId {
64
+ | Some(f) => f(event)
65
+ | None => None
66
+ }
15
67
  handler(event, ctx)
16
- ->Effect.provideService(RequestContext.tag, RequestContext.test())
68
+ ->annotateInvocation(~correlationId?, ~causationId?, ~comp?, ~pluginName?)
17
69
  ->Effect.runPromise
70
+ }
18
71
 
19
72
  type environment<'parts> = {
20
73
  parts: 'parts,
@@ -36,6 +89,7 @@ module type Environment = {
36
89
  let make: environmentMaker<event, context, 'result, parts>
37
90
  let groupBySource: event => dict<event>
38
91
  let extractCorrelationId: event => option<string>
92
+ let extractCausationId: event => option<string>
39
93
  let asEventHandler: 'a => eventHandler<event, context, 'result>
40
94
  let asEffectHandler: 'a => effectHandler<event, context, 'result, 'error>
41
95
  }
@@ -1,10 +1,31 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
  import * as Effect from "effect/Effect";
4
5
  import * as RequestContext$ReventlessCore from "../../RequestContext.res.mjs";
5
6
 
6
- function runEffectHandler(handler) {
7
- return (event, ctx) => Effect.runPromise(Effect.provideService(handler(event, ctx), RequestContext$ReventlessCore.tag, RequestContext$ReventlessCore.test(undefined, undefined, undefined)));
7
+ function annotateInvocation(effect, correlationId, causationId, comp, pluginName) {
8
+ let cid = Stdlib_Option.getOr(correlationId, "unknown");
9
+ let annotateOpt = (eff, key, value) => {
10
+ if (value !== undefined) {
11
+ return Effect.annotateLogs(eff, key, value);
12
+ } else {
13
+ return eff;
14
+ }
15
+ };
16
+ return Effect.provideService(annotateOpt(annotateOpt(Effect.annotateLogs(effect, "correlationId", cid), "comp", comp), "causationId", causationId), RequestContext$ReventlessCore.tag, RequestContext$ReventlessCore.make(cid, causationId, comp, pluginName, undefined, undefined));
17
+ }
18
+
19
+ function runEffect(correlationId, causationId, comp, pluginName, effect) {
20
+ return Effect.runPromise(annotateInvocation(effect, correlationId, causationId, comp, pluginName));
21
+ }
22
+
23
+ function runEffectHandler(extractCorrelationId, extractCausationId, comp, pluginName, handler) {
24
+ return (event, ctx) => {
25
+ let correlationId = extractCorrelationId !== undefined ? extractCorrelationId(event) : undefined;
26
+ let causationId = extractCausationId !== undefined ? extractCausationId(event) : undefined;
27
+ return Effect.runPromise(annotateInvocation(handler(event, ctx), correlationId, causationId, comp, pluginName));
28
+ };
8
29
  }
9
30
 
10
31
  let CommandHandlerDefaults = {
@@ -16,6 +37,8 @@ let CommandHandlerDefaults = {
16
37
  };
17
38
 
18
39
  export {
40
+ annotateInvocation,
41
+ runEffect,
19
42
  runEffectHandler,
20
43
  CommandHandlerDefaults,
21
44
  }
@@ -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
+ })