@reventlessdev/reventless-seed-aws 1.0.0-alpha.11 → 1.0.0-alpha.13

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 (63) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/compiler-info.json +1 -1
  4. package/lib/bs/src/ReventlessSeedAws_Quiesce.ast +0 -0
  5. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmi +0 -0
  6. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmj +0 -0
  7. package/lib/bs/src/ReventlessSeedAws_Quiesce.cmt +0 -0
  8. package/lib/bs/src/ReventlessSeedAws_Quiesce.res +287 -0
  9. package/lib/bs/src/ReventlessSeedAws_Quiesce.res.mjs +205 -0
  10. package/lib/bs/src/ReventlessSeedAws_Reconcile.ast +0 -0
  11. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmi +0 -0
  12. package/lib/bs/src/ReventlessSeedAws_Reconcile.cmt +0 -0
  13. package/lib/bs/src/ReventlessSeedAws_Reconcile.res +2 -6
  14. package/lib/bs/src/ReventlessSeedAws_Reconcile.res.mjs +2 -2
  15. package/lib/bs/src/ReventlessSeedAws_Reset.ast +0 -0
  16. package/lib/bs/src/ReventlessSeedAws_Reset.cmi +0 -0
  17. package/lib/bs/src/ReventlessSeedAws_Reset.cmj +0 -0
  18. package/lib/bs/src/ReventlessSeedAws_Reset.cmt +0 -0
  19. package/lib/bs/src/ReventlessSeedAws_Reset.res +223 -73
  20. package/lib/bs/src/ReventlessSeedAws_Reset.res.mjs +212 -82
  21. package/lib/bs/tests/ObjectStoreReconcileTest.cmi +0 -0
  22. package/lib/bs/tests/ObjectStoreReconcileTest.cmt +0 -0
  23. package/lib/bs/tests/ObjectStoreResetTest.cmi +0 -0
  24. package/lib/bs/tests/ObjectStoreResetTest.cmt +0 -0
  25. package/lib/bs/tests/QuiesceResetTest.ast +0 -0
  26. package/lib/bs/tests/QuiesceResetTest.cmi +0 -0
  27. package/lib/bs/tests/QuiesceResetTest.cmj +0 -0
  28. package/lib/bs/tests/QuiesceResetTest.cmt +0 -0
  29. package/lib/bs/tests/QuiesceResetTest.res +133 -0
  30. package/lib/bs/tests/QuiesceResetTest.res.mjs +141 -0
  31. package/lib/ocaml/.compiler.log +2 -2
  32. package/lib/ocaml/ObjectStoreReconcileTest.cmi +0 -0
  33. package/lib/ocaml/ObjectStoreReconcileTest.cmt +0 -0
  34. package/lib/ocaml/ObjectStoreResetTest.cmi +0 -0
  35. package/lib/ocaml/ObjectStoreResetTest.cmt +0 -0
  36. package/lib/ocaml/QuiesceResetTest.ast +0 -0
  37. package/lib/ocaml/QuiesceResetTest.cmi +0 -0
  38. package/lib/ocaml/QuiesceResetTest.cmj +0 -0
  39. package/lib/ocaml/QuiesceResetTest.cmt +0 -0
  40. package/lib/ocaml/QuiesceResetTest.res +133 -0
  41. package/lib/ocaml/ReventlessSeedAws_Quiesce.ast +0 -0
  42. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmi +0 -0
  43. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmj +0 -0
  44. package/lib/ocaml/ReventlessSeedAws_Quiesce.cmt +0 -0
  45. package/lib/ocaml/ReventlessSeedAws_Quiesce.res +287 -0
  46. package/lib/ocaml/ReventlessSeedAws_Reconcile.ast +0 -0
  47. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmi +0 -0
  48. package/lib/ocaml/ReventlessSeedAws_Reconcile.cmt +0 -0
  49. package/lib/ocaml/ReventlessSeedAws_Reconcile.res +2 -6
  50. package/lib/ocaml/ReventlessSeedAws_Reset.ast +0 -0
  51. package/lib/ocaml/ReventlessSeedAws_Reset.cmi +0 -0
  52. package/lib/ocaml/ReventlessSeedAws_Reset.cmj +0 -0
  53. package/lib/ocaml/ReventlessSeedAws_Reset.cmt +0 -0
  54. package/lib/ocaml/ReventlessSeedAws_Reset.res +223 -73
  55. package/package.json +4 -4
  56. package/src/ReventlessSeedAws_Quiesce.res +287 -0
  57. package/src/ReventlessSeedAws_Quiesce.res.mjs +205 -0
  58. package/src/ReventlessSeedAws_Reconcile.res +2 -6
  59. package/src/ReventlessSeedAws_Reconcile.res.mjs +2 -2
  60. package/src/ReventlessSeedAws_Reset.res +223 -73
  61. package/src/ReventlessSeedAws_Reset.res.mjs +212 -82
  62. package/tests/QuiesceResetTest.res +133 -0
  63. package/tests/QuiesceResetTest.res.mjs +141 -0
@@ -0,0 +1,141 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
+ import * as ReventlessSeedAws_Reset from "../src/ReventlessSeedAws_Reset.res.mjs";
5
+ import * as ReventlessSeedAws_Quiesce from "../src/ReventlessSeedAws_Quiesce.res.mjs";
6
+ import * as Seed_Client$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed_Client.res.mjs";
7
+
8
+ globalThis.describe("classify", () => {
9
+ globalThis.test("reads the function name out of a Lambda ARN", () => {
10
+ globalThis.expect(ReventlessSeedAws_Reset.classify("arn:aws:lambda:eu-west-1:123456789012:function:AllAutomationSlices-28b116c")).toEqual({
11
+ TAG: "Function",
12
+ _0: "AllAutomationSlices-28b116c"
13
+ });
14
+ });
15
+ globalThis.test("drops a version or alias qualifier", () => {
16
+ globalThis.expect(ReventlessSeedAws_Reset.classify("arn:aws:lambda:eu-west-1:123456789012:function:AllReadModels-0287438:42")).toEqual({
17
+ TAG: "Function",
18
+ _0: "AllReadModels-0287438"
19
+ });
20
+ });
21
+ globalThis.test("still classifies tables and buckets", () => {
22
+ globalThis.expect(ReventlessSeedAws_Reset.classify("arn:aws:dynamodb:eu-west-1:123456789012:table/Products-0ba6849")).toEqual({
23
+ TAG: "Table",
24
+ _0: "Products-0ba6849"
25
+ });
26
+ globalThis.expect(ReventlessSeedAws_Reset.classify("arn:aws:s3:::alpha-product-images")).toEqual({
27
+ TAG: "Bucket",
28
+ _0: "alpha-product-images"
29
+ });
30
+ });
31
+ globalThis.test("ignores anything else the tag scope returns", () => {
32
+ globalThis.expect(ReventlessSeedAws_Reset.classify("arn:aws:sqs:eu-west-1:123456789012:OrderingDcbCmdTopic-4b3658c")).toEqual("Other");
33
+ });
34
+ });
35
+
36
+ globalThis.describe("mapBounded", () => {
37
+ globalThis.test("keeps results in input order under out-of-order completion", async () => {
38
+ let delays = [
39
+ 40,
40
+ 0,
41
+ 30,
42
+ 10,
43
+ 20,
44
+ 5
45
+ ];
46
+ let out = await ReventlessSeedAws_Quiesce.mapBounded(delays, 3, async ms => {
47
+ await Seed_Client$ReventlessSeed.sleep(ms);
48
+ return ms;
49
+ });
50
+ globalThis.expect(out).toEqual(delays);
51
+ });
52
+ globalThis.test("never runs more than the limit at once", async () => {
53
+ let inFlight = {
54
+ contents: 0
55
+ };
56
+ let peak = {
57
+ contents: 0
58
+ };
59
+ await ReventlessSeedAws_Quiesce.mapBounded(Stdlib_Array.make(12, 0), 4, async param => {
60
+ inFlight.contents = inFlight.contents + 1 | 0;
61
+ peak.contents = Math.max(peak.contents, inFlight.contents);
62
+ await Seed_Client$ReventlessSeed.sleep(5);
63
+ inFlight.contents = inFlight.contents - 1 | 0;
64
+ });
65
+ globalThis.expect(peak.contents).toBe(4);
66
+ });
67
+ globalThis.test("an empty list spawns no workers and returns nothing", async () => {
68
+ let out = await ReventlessSeedAws_Quiesce.mapBounded([], 4, async x => x);
69
+ globalThis.expect(out.length).toBe(0);
70
+ });
71
+ });
72
+
73
+ globalThis.describe("refillMessage", () => {
74
+ globalThis.test("names every store that came back, with its count", () => {
75
+ let message = ReventlessSeedAws_Reset.refillMessage([
76
+ [
77
+ "AutoShipOrderTodo-61f39a2",
78
+ 12
79
+ ],
80
+ [
81
+ "GeocodeCustomerAddressTodo-50d9488",
82
+ 1
83
+ ]
84
+ ], 41);
85
+ globalThis.expect(message.includes("AutoShipOrderTodo-61f39a2")).toBe(true);
86
+ globalThis.expect(message.includes("GeocodeCustomerAddressTodo-50d9488")).toBe(true);
87
+ globalThis.expect(message.startsWith("13 item(s)/object(s) came back")).toBe(true);
88
+ });
89
+ globalThis.test("advises re-running only when the runtimes were held", () => {
90
+ let message = ReventlessSeedAws_Reset.refillMessage([[
91
+ "AutoShipOrderTodo-61f39a2",
92
+ 12
93
+ ]], 41);
94
+ globalThis.expect(message.includes("already in flight")).toBe(true);
95
+ globalThis.expect(message.includes("Re-running clears it")).toBe(true);
96
+ });
97
+ globalThis.test("refuses to advise re-running when the stack was not held", () => {
98
+ let message = ReventlessSeedAws_Reset.refillMessage([[
99
+ "AutoShipOrderTodo-61f39a2",
100
+ 12
101
+ ]], 0);
102
+ globalThis.expect(message.includes("re-running will not help".toLowerCase())).toBe(true);
103
+ globalThis.expect(message.includes("SEED_RESET_NO_QUIESCE")).toBe(true);
104
+ });
105
+ });
106
+
107
+ globalThis.describe("noQuiesce", () => {
108
+ let set = v => {
109
+ process.env["SEED_RESET_NO_QUIESCE"] = v;
110
+ };
111
+ globalThis.test("defaults to holding the platform", () => {
112
+ set("");
113
+ globalThis.expect(ReventlessSeedAws_Reset.noQuiesce()).toBe(false);
114
+ set("0");
115
+ globalThis.expect(ReventlessSeedAws_Reset.noQuiesce()).toBe(false);
116
+ set("no");
117
+ globalThis.expect(ReventlessSeedAws_Reset.noQuiesce()).toBe(false);
118
+ });
119
+ globalThis.test("accepts the spellings an operator would actually type", () => {
120
+ [
121
+ "1",
122
+ "true",
123
+ "TRUE",
124
+ " yes "
125
+ ].forEach(v => {
126
+ set(v);
127
+ globalThis.expect(ReventlessSeedAws_Reset.noQuiesce()).toBe(true);
128
+ });
129
+ set("");
130
+ });
131
+ });
132
+
133
+ let Reset;
134
+
135
+ let Quiesce;
136
+
137
+ export {
138
+ Reset,
139
+ Quiesce,
140
+ }
141
+ /* Not a pure module */
@@ -1,2 +1,2 @@
1
- #Start(1785775483095)
2
- #Done(1785775483128)
1
+ #Start(1786243797857)
2
+ #Done(1786243797920)
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,133 @@
1
+ // Holding a stack's runtimes still for the length of a wipe: what gets held, and
2
+ // what the reset says when a store comes back anyway.
3
+ //
4
+ // A truncate is not durable while the runtimes that own the data are running — a
5
+ // slice runtime keeps its TODO list in memory for the life of its execution
6
+ // environment and re-saves every row it holds at the end of each invocation,
7
+ // including the scheduled sweep that carries no events. So the reset now
8
+ // discovers the stack's Lambda functions alongside its stores and holds them at
9
+ // zero concurrency across the wipe.
10
+ //
11
+ // The pure half of that is what these pin: which ARNs resolve to a function to
12
+ // hold, the bounded fan-out the control-plane calls go through, and the failure
13
+ // message — which used to say "re-run to finish" for a condition re-running
14
+ // could not clear. The impure half is the Lambda control-plane calls themselves.
15
+
16
+ open JestGlobals
17
+
18
+ module Reset = ReventlessSeedAws_Reset
19
+ module Quiesce = ReventlessSeedAws_Quiesce
20
+
21
+ describe("classify", () => {
22
+ testSync("reads the function name out of a Lambda ARN", () =>
23
+ expect(Reset.classify("arn:aws:lambda:eu-west-1:123456789012:function:AllAutomationSlices-28b116c"))
24
+ ->toEqual(Reset.Function("AllAutomationSlices-28b116c"))
25
+ )
26
+
27
+ // The tagging API returns unqualified ARNs, but a qualified one must still
28
+ // yield the function name — every Lambda control-plane call the hold makes
29
+ // takes a name, and "Name:1" is not one.
30
+ testSync("drops a version or alias qualifier", () =>
31
+ expect(Reset.classify("arn:aws:lambda:eu-west-1:123456789012:function:AllReadModels-0287438:42"))
32
+ ->toEqual(Reset.Function("AllReadModels-0287438"))
33
+ )
34
+
35
+ testSync("still classifies tables and buckets", () => {
36
+ expect(Reset.classify("arn:aws:dynamodb:eu-west-1:123456789012:table/Products-0ba6849"))
37
+ ->toEqual(Reset.Table("Products-0ba6849"))
38
+ expect(Reset.classify("arn:aws:s3:::alpha-product-images"))->toEqual(
39
+ Reset.Bucket("alpha-product-images"),
40
+ )
41
+ })
42
+
43
+ testSync("ignores anything else the tag scope returns", () =>
44
+ expect(Reset.classify("arn:aws:sqs:eu-west-1:123456789012:OrderingDcbCmdTopic-4b3658c"))
45
+ ->toEqual(Reset.Other)
46
+ )
47
+ })
48
+
49
+ describe("mapBounded", () => {
50
+ // Results must line up with their inputs regardless of which worker finished
51
+ // first: the hold pairs each result with the function it came from, and a
52
+ // shuffled array would restore the wrong concurrency to the wrong function.
53
+ test("keeps results in input order under out-of-order completion", async () => {
54
+ let delays = [40, 0, 30, 10, 20, 5]
55
+ let out = await delays->Quiesce.mapBounded(~limit=3, async ms => {
56
+ await ReventlessSeed.Seed.Client.sleep(ms)
57
+ ms
58
+ })
59
+ expect(out)->toEqual(delays)
60
+ })
61
+
62
+ test("never runs more than the limit at once", async () => {
63
+ let inFlight = ref(0)
64
+ let peak = ref(0)
65
+ let _ = await Array.make(~length=12, 0)->Quiesce.mapBounded(~limit=4, async _ => {
66
+ inFlight := inFlight.contents + 1
67
+ peak := Math.Int.max(peak.contents, inFlight.contents)
68
+ await ReventlessSeed.Seed.Client.sleep(5)
69
+ inFlight := inFlight.contents - 1
70
+ })
71
+ expect(peak.contents)->toBe(4)
72
+ })
73
+
74
+ test("an empty list spawns no workers and returns nothing", async () => {
75
+ let out = await []->Quiesce.mapBounded(~limit=4, async x => x)
76
+ expect(out->Array.length)->toBe(0)
77
+ })
78
+ })
79
+
80
+ describe("refillMessage", () => {
81
+ // The whole point of the change: the operator has to be able to see WHICH
82
+ // store came back, not just a total.
83
+ testSync("names every store that came back, with its count", () => {
84
+ let message = Reset.refillMessage(
85
+ ~refilled=[("AutoShipOrderTodo-61f39a2", 12), ("GeocodeCustomerAddressTodo-50d9488", 1)],
86
+ ~heldCount=41,
87
+ )
88
+ expect(message->String.includes("AutoShipOrderTodo-61f39a2"))->toBe(true)
89
+ expect(message->String.includes("GeocodeCustomerAddressTodo-50d9488"))->toBe(true)
90
+ expect(message->String.startsWith("13 item(s)/object(s) came back"))->toBe(true)
91
+ })
92
+
93
+ // Under a hold nothing could START during the wipe, so the only writer left is
94
+ // an invocation already running when the hold landed — and that one really
95
+ // does go away. This is the only case where "re-run" is honest advice.
96
+ testSync("advises re-running only when the runtimes were held", () => {
97
+ let message = Reset.refillMessage(~refilled=[("AutoShipOrderTodo-61f39a2", 12)], ~heldCount=41)
98
+ expect(message->String.includes("already in flight"))->toBe(true)
99
+ expect(message->String.includes("Re-running clears it"))->toBe(true)
100
+ })
101
+
102
+ // Without a hold, the writer holds the rows in memory and restores them every
103
+ // invocation — the exact condition the old "re-run to finish" sent operators
104
+ // round in circles on. It must not appear here.
105
+ testSync("refuses to advise re-running when the stack was not held", () => {
106
+ let message = Reset.refillMessage(~refilled=[("AutoShipOrderTodo-61f39a2", 12)], ~heldCount=0)
107
+ expect(message->String.includes("re-running will not help"->String.toLowerCase))->toBe(true)
108
+ expect(message->String.includes("SEED_RESET_NO_QUIESCE"))->toBe(true)
109
+ })
110
+ })
111
+
112
+ describe("noQuiesce", () => {
113
+ let set = v => NodeProcess.env->Dict.set("SEED_RESET_NO_QUIESCE", v)
114
+
115
+ // Fail-closed: the hold is what makes the wipe durable, so anything other than
116
+ // a deliberate opt-out keeps it on.
117
+ testSync("defaults to holding the platform", () => {
118
+ set("")
119
+ expect(Reset.noQuiesce())->toBe(false)
120
+ set("0")
121
+ expect(Reset.noQuiesce())->toBe(false)
122
+ set("no")
123
+ expect(Reset.noQuiesce())->toBe(false)
124
+ })
125
+
126
+ testSync("accepts the spellings an operator would actually type", () => {
127
+ ["1", "true", "TRUE", " yes "]->Array.forEach(v => {
128
+ set(v)
129
+ expect(Reset.noQuiesce())->toBe(true)
130
+ })
131
+ set("")
132
+ })
133
+ })
@@ -0,0 +1,287 @@
1
+ // Taking a deployed stack's runtimes out of service for the length of a wipe,
2
+ // and putting them back exactly as they were.
3
+ //
4
+ // Why a wipe needs this at all: a truncate is not durable while the runtimes
5
+ // that own the data are running. A slice runtime keeps its TODO list in a
6
+ // module-level dict for the lifetime of its execution environment and re-saves
7
+ // every row it holds at the end of each invocation — including the scheduled
8
+ // sweep, which carries no events. Emptying the table under a warm container
9
+ // therefore succeeds and is then undone, byte for byte, by the next sweep: the
10
+ // rows are not recomputed from anything, so their ids and timestamps come back
11
+ // identical. Emptying the upstream stores first does not help, because nothing
12
+ // upstream is read.
13
+ //
14
+ // The answer is to remove the contention rather than to outrun it, which takes
15
+ // two distinct steps, in this order:
16
+ //
17
+ // 1. HOLD — reserve 0 concurrency on every function in scope, so no new
18
+ // invocation can start while the wipe and its verification run. This alone
19
+ // is not enough: a reservation stops invocations, it does not discard the
20
+ // execution environment that holds the state.
21
+ // 2. RECYCLE — change each function's configuration once. A configuration
22
+ // change is the documented way to make Lambda discard existing execution
23
+ // environments, so whatever a warm container was holding is gone before it
24
+ // can be invoked again. Done while still held, so the container that
25
+ // eventually replaces it starts from the emptied store.
26
+ //
27
+ // The recycle is a round trip — a marker variable added, then the original map
28
+ // put back — so the function ends the run byte-identical to how it started and
29
+ // `pulumi preview` reports no drift. The second update recycles again, which is
30
+ // harmless: it happens while the hold is still on and the store is already
31
+ // empty.
32
+ //
33
+ // Everything acquired here is released on the failure path as well as the
34
+ // success path, and the release never throws: a reset that aborted mid-wipe
35
+ // must still hand the stack back. Release failures come back as warnings for
36
+ // the caller to print, because a function left at zero concurrency is a stack
37
+ // left switched off, and that has to be said out loud rather than raised as one
38
+ // more exception on top of whatever already went wrong.
39
+
40
+ module Lambda = AwsSdk.Lambda
41
+
42
+ /** The variable a recycle adds and then removes. Named for what it is, so an
43
+ operator who finds one left behind by a killed run knows it is inert and can
44
+ be deleted (or left for the next `pulumi up` to remove). */
45
+ let markerKey = "REVENTLESS_RESET_RECYCLE"
46
+
47
+ /** What one function looked like before the reset touched it. */
48
+ type held = {
49
+ functionName: string,
50
+ /** `None` = the function had no reservation at all. That is a different state
51
+ from a reservation of 0, and restoring it needs
52
+ `DeleteFunctionConcurrency` rather than a put. */
53
+ priorReserved: option<int>,
54
+ /** The variable map as it was, flattened: a function with no `Environment`
55
+ block at all reads as an empty map and is restored to an empty map. */
56
+ priorVariables: dict<string>,
57
+ }
58
+
59
+ /** The Lambda control plane is rate-limited well below the data plane, and a
60
+ reset touches every function in a project at once. Six at a time keeps a
61
+ forty-function stack under a minute per pass without risking a throttle. */
62
+ let concurrency = 6
63
+
64
+ let mapBounded = async (items: array<'a>, ~limit: int, f: 'a => promise<'b>): array<'b> => {
65
+ let results = Array.make(~length=items->Array.length, None)
66
+ let next = ref(0)
67
+ let worker = async () => {
68
+ let running = ref(true)
69
+ while running.contents {
70
+ let i = next.contents
71
+ if i >= items->Array.length {
72
+ running := false
73
+ } else {
74
+ next := i + 1
75
+ let r = await f(items->Array.getUnsafe(i))
76
+ results->Array.set(i, Some(r))
77
+ }
78
+ }
79
+ }
80
+ let _ = await Array.fromInitializer(~length=Math.Int.min(limit, items->Array.length), _ =>
81
+ worker()
82
+ )->Promise.all
83
+ results->Array.filterMap(x => x)
84
+ }
85
+
86
+ let errorText = (exn: exn): string =>
87
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
88
+
89
+ // An update is rejected while another is still settling, and a stack that was
90
+ // deployed moments ago can still be settling. Poll rather than assume.
91
+ let awaitSettled = async (~client, ~functionName: string): unit => {
92
+ let deadline = 60
93
+ let rec poll = async (attempt: int): unit =>
94
+ if attempt < deadline {
95
+ let out = await Lambda.GetFunctionConfigurationCommand.send(
96
+ client,
97
+ Lambda.GetFunctionConfigurationCommand.make({functionName: functionName}),
98
+ )
99
+ switch out.lastUpdateStatus {
100
+ | Some("InProgress") =>
101
+ await ReventlessSeed.Seed.Client.sleep(1000)
102
+ await poll(attempt + 1)
103
+ | _ => ()
104
+ }
105
+ }
106
+ await poll(0)
107
+ }
108
+
109
+ let updateVariables = async (~client, ~functionName: string, ~variables: dict<string>): unit => {
110
+ await awaitSettled(~client, ~functionName)
111
+ let _ = await Lambda.UpdateFunctionConfigurationCommand.send(
112
+ client,
113
+ Lambda.UpdateFunctionConfigurationCommand.make({
114
+ functionName,
115
+ environment: {variables: variables},
116
+ }),
117
+ )
118
+ await awaitSettled(~client, ~functionName)
119
+ }
120
+
121
+ /** Put one function's reservation back exactly as it was — a value it had, or no
122
+ reservation at all. Never throws: a function left reserved at 0 is a function
123
+ left switched off, which has to be said rather than raised. */
124
+ let restoreConcurrency = async (~client, ~h: held): option<string> =>
125
+ switch await (
126
+ switch h.priorReserved {
127
+ | Some(n) =>
128
+ Lambda.PutFunctionConcurrencyCommand.send(
129
+ client,
130
+ Lambda.PutFunctionConcurrencyCommand.make({
131
+ functionName: h.functionName,
132
+ reservedConcurrentExecutions: n,
133
+ }),
134
+ )->Promise.thenResolve(_ => ())
135
+ | None =>
136
+ Lambda.DeleteFunctionConcurrencyCommand.send(
137
+ client,
138
+ Lambda.DeleteFunctionConcurrencyCommand.make({functionName: h.functionName}),
139
+ )->Promise.thenResolve(_ => ())
140
+ }
141
+ ) {
142
+ | () => None
143
+ | exception exn =>
144
+ Some(
145
+ `LEFT SWITCHED OFF: could not restore concurrency on ${h.functionName} (${errorText(exn)}). ` ++
146
+ `It is still reserved at 0 and will not run until that is undone.`,
147
+ )
148
+ }
149
+
150
+ /**
151
+ Hand the stack back: original environment first (which also drops the marker),
152
+ then the original concurrency.
153
+
154
+ That order matters. The environment restore is itself a configuration change, so
155
+ it recycles a second time; doing it while the function is still held means the
156
+ container that finally serves traffic is created after the restore, not during
157
+ it.
158
+
159
+ Never throws, for either step and for any function — this runs on the abort path
160
+ too, where an exception would replace the reason the reset failed with a
161
+ secondary one.
162
+ */
163
+ let release = async (~client, ~held: array<held>): array<string> => {
164
+ let warnings = await held->mapBounded(~limit=concurrency, async h => {
165
+ let envWarning = switch await updateVariables(
166
+ ~client,
167
+ ~functionName=h.functionName,
168
+ ~variables=h.priorVariables,
169
+ ) {
170
+ | () => None
171
+ | exception exn =>
172
+ Some(
173
+ `could not restore the environment of ${h.functionName} (${errorText(exn)}) — it may still ` ++
174
+ `carry ${markerKey}, which is inert and is removed by the next deploy.`,
175
+ )
176
+ }
177
+ let concurrencyWarning = await restoreConcurrency(~client, ~h)
178
+ [envWarning, concurrencyWarning]->Array.filterMap(w => w)
179
+ })
180
+ warnings->Array.flat
181
+ }
182
+
183
+ /**
184
+ Discard every held function's execution environments, so nothing that loaded
185
+ state before the wipe can write it back afterwards.
186
+
187
+ Never throws. A function that cannot be recycled is the one case where the reset
188
+ genuinely cannot promise the store stays empty, so it comes back as a warning
189
+ naming the function — the caller reports it rather than swallowing it.
190
+ */
191
+ let recycle = async (~client, ~held: array<held>): array<string> => {
192
+ let warnings = await held->mapBounded(~limit=concurrency, async h => {
193
+ let marked = h.priorVariables->Dict.copy
194
+ marked->Dict.set(markerKey, "1")
195
+ switch await updateVariables(~client, ~functionName=h.functionName, ~variables=marked) {
196
+ | () => None
197
+ | exception exn =>
198
+ Some(
199
+ `could not recycle ${h.functionName} (${errorText(exn)}) — a warm container may still hold ` ++
200
+ `pre-wipe state and write it back. Redeploy or update that function to clear it.`,
201
+ )
202
+ }
203
+ })
204
+ warnings->Array.filterMap(w => w)
205
+ }
206
+
207
+ /**
208
+ Take every named function out of service, recording what to put back.
209
+
210
+ Throws if any function cannot be held — before a single delete has been issued,
211
+ so a reset that cannot hold the platform still has the option of not starting.
212
+ The message names the IAM actions involved, because a missing permission is much
213
+ the likeliest cause and "AccessDenied" on its own does not say which one.
214
+
215
+ Crucially, it hands back whatever it did manage to take before throwing. The
216
+ functions are held concurrently, so a failure part-way through the batch leaves
217
+ earlier ones already at zero — and a reset that aborted while silently switching
218
+ off half a stack would be a worse failure than the one it was reporting. Only the
219
+ reservations are rolled back: this runs before `recycle`, so no environment has
220
+ been touched yet.
221
+ */
222
+ let hold = async (~client, ~functionNames: array<string>): array<held> => {
223
+ let attempts = await functionNames->mapBounded(~limit=concurrency, async functionName =>
224
+ switch await (
225
+ async () => {
226
+ let concurrencyOut = await Lambda.GetFunctionConcurrencyCommand.send(
227
+ client,
228
+ Lambda.GetFunctionConcurrencyCommand.make({functionName: functionName}),
229
+ )
230
+ let configOut = await Lambda.GetFunctionConfigurationCommand.send(
231
+ client,
232
+ Lambda.GetFunctionConfigurationCommand.make({functionName: functionName}),
233
+ )
234
+ let held = {
235
+ functionName,
236
+ priorReserved: concurrencyOut.reservedConcurrentExecutions,
237
+ priorVariables: configOut.environment
238
+ ->Option.flatMap(e => e.variables)
239
+ ->Option.getOr(Dict.make()),
240
+ }
241
+ let _ = await Lambda.PutFunctionConcurrencyCommand.send(
242
+ client,
243
+ Lambda.PutFunctionConcurrencyCommand.make({
244
+ functionName,
245
+ reservedConcurrentExecutions: 0,
246
+ }),
247
+ )
248
+ held
249
+ }
250
+ )() {
251
+ | held => Ok(held)
252
+ | exception exn => Error((functionName, errorText(exn)))
253
+ }
254
+ )
255
+
256
+ let taken = attempts->Array.filterMap(a =>
257
+ switch a {
258
+ | Ok(h) => Some(h)
259
+ | Error(_) => None
260
+ }
261
+ )
262
+ switch attempts->Array.findMap(a =>
263
+ switch a {
264
+ | Error(failure) => Some(failure)
265
+ | Ok(_) => None
266
+ }
267
+ ) {
268
+ | None => taken
269
+ | Some((functionName, reason)) =>
270
+ let rollback = await taken->mapBounded(~limit=concurrency, h => restoreConcurrency(~client, ~h))
271
+ let stranded = rollback->Array.filterMap(w => w)
272
+ throw(
273
+ ReventlessSeed.Seed.Failed(
274
+ `could not hold Lambda function ${functionName} at zero concurrency (${reason}) — nothing was ` ++
275
+ `deleted. The reset holds every runtime in scope for the length of the wipe, which needs ` ++
276
+ `lambda:GetFunctionConcurrency, lambda:GetFunctionConfiguration, lambda:PutFunctionConcurrency, ` ++
277
+ `lambda:DeleteFunctionConcurrency and lambda:UpdateFunctionConfiguration. Set ` ++
278
+ `SEED_RESET_NO_QUIESCE=1 to wipe without the hold — but a running runtime can then write back ` ++
279
+ `what the wipe removes.` ++
280
+ switch stranded {
281
+ | [] => ""
282
+ | messages => `\n\n ` ++ messages->Array.join("\n ")
283
+ },
284
+ ),
285
+ )
286
+ }
287
+ }
@@ -333,12 +333,8 @@ let run = (~stack=?, ~backend=?, ~targets: array<ReventlessSeedAws_Reset.target>
333
333
  await targets->Array.reduce(Promise.resolve(), (acc, target) =>
334
334
  acc->Promise.then(async _ => {
335
335
  let platform = ReventlessSeedAws_Reset.projectName(~projectDir=target.projectDir)
336
- let (found, _buckets) = await ReventlessSeedAws_Reset.discover(
337
- ~region,
338
- ~stack,
339
- ~platform,
340
- )
341
- found->Array.forEach(t => tables->Array.push(t))
336
+ let found = await ReventlessSeedAws_Reset.discover(~region, ~stack, ~platform)
337
+ found.tables->Array.forEach(t => tables->Array.push(t))
342
338
  })
343
339
  )
344
340