@fluid-experimental/pact-map 2.0.0-dev.3.1.0.125672

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 (54) hide show
  1. package/.eslintrc.js +25 -0
  2. package/.mocharc.js +12 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -0
  5. package/api-extractor.json +4 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +10 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/interfaces.d.ts +55 -0
  11. package/dist/interfaces.d.ts.map +1 -0
  12. package/dist/interfaces.js +7 -0
  13. package/dist/interfaces.js.map +1 -0
  14. package/dist/packageVersion.d.ts +9 -0
  15. package/dist/packageVersion.d.ts.map +1 -0
  16. package/dist/packageVersion.js +12 -0
  17. package/dist/packageVersion.js.map +1 -0
  18. package/dist/pactMap.d.ts +162 -0
  19. package/dist/pactMap.d.ts.map +1 -0
  20. package/dist/pactMap.js +352 -0
  21. package/dist/pactMap.js.map +1 -0
  22. package/dist/pactMapFactory.d.ts +21 -0
  23. package/dist/pactMapFactory.d.ts.map +1 -0
  24. package/dist/pactMapFactory.js +41 -0
  25. package/dist/pactMapFactory.js.map +1 -0
  26. package/lib/index.d.ts +7 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +6 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/interfaces.d.ts +55 -0
  31. package/lib/interfaces.d.ts.map +1 -0
  32. package/lib/interfaces.js +6 -0
  33. package/lib/interfaces.js.map +1 -0
  34. package/lib/packageVersion.d.ts +9 -0
  35. package/lib/packageVersion.d.ts.map +1 -0
  36. package/lib/packageVersion.js +9 -0
  37. package/lib/packageVersion.js.map +1 -0
  38. package/lib/pactMap.d.ts +162 -0
  39. package/lib/pactMap.d.ts.map +1 -0
  40. package/lib/pactMap.js +348 -0
  41. package/lib/pactMap.js.map +1 -0
  42. package/lib/pactMapFactory.d.ts +21 -0
  43. package/lib/pactMapFactory.d.ts.map +1 -0
  44. package/lib/pactMapFactory.js +37 -0
  45. package/lib/pactMapFactory.js.map +1 -0
  46. package/package.json +98 -0
  47. package/prettier.config.cjs +8 -0
  48. package/src/index.ts +7 -0
  49. package/src/interfaces.ts +61 -0
  50. package/src/packageVersion.ts +9 -0
  51. package/src/pactMap.ts +511 -0
  52. package/src/pactMapFactory.ts +55 -0
  53. package/tsconfig.esnext.json +7 -0
  54. package/tsconfig.json +10 -0
package/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "@fluid-experimental/pact-map",
3
+ "version": "2.0.0-dev.3.1.0.125672",
4
+ "description": "Distributed data structure for key-value pairs using pact consensus",
5
+ "homepage": "https://fluidframework.com",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/microsoft/FluidFramework.git",
9
+ "directory": "packages/dds/pact-map"
10
+ },
11
+ "license": "MIT",
12
+ "author": "Microsoft and contributors",
13
+ "sideEffects": false,
14
+ "main": "dist/index.js",
15
+ "module": "lib/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "scripts": {
18
+ "build": "npm run build:genver && concurrently npm:build:compile npm:lint && npm run build:docs",
19
+ "build:commonjs": "npm run tsc && npm run build:test",
20
+ "build:compile": "concurrently npm:build:commonjs npm:build:esnext",
21
+ "build:docs": "api-extractor run --local --typescript-compiler-folder ../../../node_modules/typescript && copyfiles -u 1 ./_api-extractor-temp/doc-models/* ../../../_api-extractor-temp/",
22
+ "build:esnext": "tsc --project ./tsconfig.esnext.json",
23
+ "build:full": "npm run build",
24
+ "build:full:compile": "npm run build:compile",
25
+ "build:genver": "gen-version",
26
+ "build:test": "tsc --project ./src/test/tsconfig.json",
27
+ "ci:build:docs": "api-extractor run --typescript-compiler-folder ../../../node_modules/typescript && copyfiles -u 1 ./_api-extractor-temp/* ../../../_api-extractor-temp/",
28
+ "clean": "rimraf dist lib *.tsbuildinfo *.build.log",
29
+ "eslint": "eslint --format stylish src",
30
+ "eslint:fix": "eslint --format stylish src --fix --fix-type problem,suggestion,layout",
31
+ "format": "npm run prettier:fix",
32
+ "lint": "npm run prettier && npm run eslint",
33
+ "lint:fix": "npm run prettier:fix && npm run eslint:fix",
34
+ "prettier": "prettier --check . --ignore-path ../../../.prettierignore",
35
+ "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore",
36
+ "test": "npm run test:mocha",
37
+ "test:coverage": "nyc npm test -- --reporter mocha-junit-reporter --reporter-options mochaFile=nyc/junit-report.xml",
38
+ "test:mocha": "mocha --recursive dist/test -r node_modules/@fluidframework/mocha-test-setup --unhandled-rejections=strict",
39
+ "test:mocha:verbose": "cross-env FLUID_TEST_VERBOSE=1 npm run test:mocha",
40
+ "tsc": "tsc"
41
+ },
42
+ "nyc": {
43
+ "all": true,
44
+ "cache-dir": "nyc/.cache",
45
+ "exclude": [
46
+ "src/test/**/*.ts",
47
+ "dist/test/**/*.js"
48
+ ],
49
+ "exclude-after-remap": false,
50
+ "include": [
51
+ "src/**/*.ts",
52
+ "dist/**/*.js"
53
+ ],
54
+ "report-dir": "nyc/report",
55
+ "reporter": [
56
+ "cobertura",
57
+ "html",
58
+ "text"
59
+ ],
60
+ "temp-directory": "nyc/.nyc_output"
61
+ },
62
+ "dependencies": {
63
+ "@fluidframework/common-definitions": "^0.20.1",
64
+ "@fluidframework/common-utils": "^1.0.0",
65
+ "@fluidframework/core-interfaces": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
66
+ "@fluidframework/datastore-definitions": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
67
+ "@fluidframework/driver-utils": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
68
+ "@fluidframework/protocol-definitions": "^1.1.0",
69
+ "@fluidframework/runtime-definitions": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
70
+ "@fluidframework/shared-object-base": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
71
+ "events": "^3.1.0"
72
+ },
73
+ "devDependencies": {
74
+ "@fluid-internal/test-dds-utils": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
75
+ "@fluidframework/build-common": "^1.1.0",
76
+ "@fluidframework/eslint-config-fluid": "^2.0.0",
77
+ "@fluidframework/mocha-test-setup": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
78
+ "@fluidframework/test-runtime-utils": ">=2.0.0-dev.3.1.0.125672 <2.0.0-dev.4.0.0",
79
+ "@microsoft/api-extractor": "^7.22.2",
80
+ "@rushstack/eslint-config": "^2.5.1",
81
+ "@types/events": "^3.0.0",
82
+ "@types/mocha": "^9.1.1",
83
+ "@types/node": "^14.18.36",
84
+ "concurrently": "^6.2.0",
85
+ "copyfiles": "^2.4.1",
86
+ "cross-env": "^7.0.2",
87
+ "eslint": "~8.6.0",
88
+ "mocha": "^10.0.0",
89
+ "mocha-junit-reporter": "^1.18.0",
90
+ "nyc": "^15.0.0",
91
+ "prettier": "~2.6.2",
92
+ "rimraf": "^2.6.2",
93
+ "typescript": "~4.5.5"
94
+ },
95
+ "typeValidation": {
96
+ "disabled": true
97
+ }
98
+ }
@@ -0,0 +1,8 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ module.exports = {
7
+ ...require("@fluidframework/build-common/prettier.config.cjs"),
8
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ export { IPactMap, IPactMapEvents } from "./interfaces";
7
+ export { PactMap } from "./pactMap";
@@ -0,0 +1,61 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ import { ISharedObject, ISharedObjectEvents } from "@fluidframework/shared-object-base";
7
+
8
+ /**
9
+ * IPactMapEvents are the events fired by an IPactMap.
10
+ */
11
+ export interface IPactMapEvents extends ISharedObjectEvents {
12
+ /**
13
+ * Notifies when a new value goes pending or has been accepted.
14
+ */
15
+ (event: "pending" | "accepted", listener: (key: string) => void);
16
+ }
17
+
18
+ /**
19
+ * An IPactMap is a key-value storage, in which setting a value is done via a proposal system. All collaborators
20
+ * who were connected at the time of the proposal must accept the change before it is considered accepted (or, if
21
+ * those clients disconnect they are considered to have implicitly accepted). As a result, the value goes through
22
+ * two phases:
23
+ * 1. "pending" state where the proposal has been sequenced, but there are still outstanding acceptances
24
+ * 2. "accepted" state where all clients who were connected at the time the proposal was made have either accepted
25
+ * or disconnected.
26
+ */
27
+ export interface IPactMap<T = unknown> extends ISharedObject<IPactMapEvents> {
28
+ /**
29
+ * Gets the accepted value for the given key.
30
+ * @param key - The key to retrieve from
31
+ */
32
+ get(key: string): T | undefined;
33
+
34
+ /**
35
+ * Returns whether there is a pending value for the given key. Can be used to distinguish a pending delete vs.
36
+ * nothing pending when getPending would just return undefined.
37
+ * @param key - The key to check
38
+ */
39
+ isPending(key: string): boolean;
40
+
41
+ /**
42
+ * Gets the pending value for the given key.
43
+ * @param key - The key to retrieve from
44
+ */
45
+ getPending(key: string): T | undefined;
46
+
47
+ /**
48
+ * Sets the value for the given key. After setting the value, it will be in "pending" state until all connected
49
+ * clients have approved the change. The accepted value remains unchanged until that time.
50
+ * @param key - The key to set
51
+ * @param value - The value to store
52
+ */
53
+ set(key: string, value: T | undefined): void;
54
+
55
+ /**
56
+ * Deletes the key/value pair at the given key. After issuing the delete, the delete is in "pending" state until
57
+ * all connected clients have approved the delete. The accepted value remains unchanged until that time.
58
+ * @param key - the key to delete
59
+ */
60
+ delete(key: string): void;
61
+ }
@@ -0,0 +1,9 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ *
5
+ * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY
6
+ */
7
+
8
+ export const pkgName = "@fluid-experimental/pact-map";
9
+ export const pkgVersion = "2.0.0-dev.3.1.0.125672";
package/src/pactMap.ts ADDED
@@ -0,0 +1,511 @@
1
+ /*!
2
+ * Copyright (c) Microsoft Corporation and contributors. All rights reserved.
3
+ * Licensed under the MIT License.
4
+ */
5
+
6
+ /* eslint-disable unicorn/numeric-separators-style */
7
+
8
+ import { EventEmitter } from "events";
9
+
10
+ import { assert } from "@fluidframework/common-utils";
11
+ import { ISequencedDocumentMessage, MessageType } from "@fluidframework/protocol-definitions";
12
+ import {
13
+ IChannelAttributes,
14
+ IFluidDataStoreRuntime,
15
+ IChannelStorageService,
16
+ IChannelFactory,
17
+ } from "@fluidframework/datastore-definitions";
18
+ import { ISummaryTreeWithStats } from "@fluidframework/runtime-definitions";
19
+ import { readAndParse } from "@fluidframework/driver-utils";
20
+ import {
21
+ createSingleBlobSummary,
22
+ IFluidSerializer,
23
+ SharedObject,
24
+ } from "@fluidframework/shared-object-base";
25
+ import { PactMapFactory } from "./pactMapFactory";
26
+ import { IPactMap, IPactMapEvents } from "./interfaces";
27
+
28
+ /**
29
+ * The accepted pact information, if any.
30
+ */
31
+ interface IAcceptedPact<T> {
32
+ /**
33
+ * The accepted value of the given type or undefined (typically in case of delete).
34
+ */
35
+ value: T | undefined;
36
+
37
+ /**
38
+ * The sequence number when the value was accepted, which will normally coincide with one of three possibilities:
39
+ * - The sequence number of the "accept" op from the final client we expected signoff from
40
+ * - The sequence number of the ClientLeave of the final client we expected signoff from
41
+ * - The sequence number of the "set" op, if there were no expected signoffs (i.e. only the submitting client
42
+ * was connected when the op was sequenced)
43
+ *
44
+ * For values set in detached state, it will be 0.
45
+ */
46
+ sequenceNumber: number;
47
+ }
48
+
49
+ /**
50
+ * The pending pact information, if any.
51
+ */
52
+ interface IPendingPact<T> {
53
+ /**
54
+ * The pending value of the given type or undefined (typically in case of delete).
55
+ */
56
+ value: T | undefined;
57
+ /**
58
+ * The list of clientIds that we expect "accept" ops from. Clients are also removed from this list if they
59
+ * disconnect without accepting. When this list empties, the pending value transitions to accepted.
60
+ */
61
+ expectedSignoffs: string[];
62
+ }
63
+
64
+ /**
65
+ * Internal format of the values stored in the PactMap.
66
+ */
67
+ type Pact<T> =
68
+ | { accepted: IAcceptedPact<T>; pending: undefined }
69
+ | { accepted: undefined; pending: IPendingPact<T> }
70
+ | { accepted: IAcceptedPact<T>; pending: IPendingPact<T> };
71
+
72
+ /**
73
+ * PactMap operation formats
74
+ */
75
+ interface IPactMapSetOperation<T> {
76
+ type: "set";
77
+ key: string;
78
+ value: T | undefined;
79
+
80
+ /**
81
+ * A "set" is only valid if it is made with knowledge of the most-recent accepted proposal - its reference
82
+ * sequence number is greater than or equal to the sequence number when that prior value was accepted.
83
+ *
84
+ * However, we can't trust the built-in referenceSequenceNumber of the op because of resubmit on reconnect,
85
+ * which will update the referenceSequenceNumber on our behalf.
86
+ *
87
+ * Instead we need to separately stamp the real reference sequence number on the op itself.
88
+ */
89
+ refSeq: number;
90
+ }
91
+
92
+ interface IPactMapAcceptOperation {
93
+ type: "accept";
94
+ key: string;
95
+ }
96
+
97
+ type IPactMapOperation<T> = IPactMapSetOperation<T> | IPactMapAcceptOperation;
98
+
99
+ const snapshotFileName = "header";
100
+
101
+ /**
102
+ * The PactMap distributed data structure provides key/value storage with a cautious conflict resolution strategy.
103
+ * This strategy optimizes for all clients being aware of the change prior to considering the value as accepted.
104
+ *
105
+ * It is still experimental and under development. Please do try it out, but expect breaking changes in the future.
106
+ *
107
+ * @remarks
108
+ * ### Creation
109
+ *
110
+ * To create a `PactMap`, call the static create method:
111
+ *
112
+ * ```typescript
113
+ * const pactMap = PactMap.create(this.runtime, id);
114
+ * ```
115
+ *
116
+ * ### Usage
117
+ *
118
+ * Setting and reading values is somewhat similar to a `SharedMap`. However, because the acceptance strategy
119
+ * cannot be resolved until other clients have witnessed the set, the new value will only be reflected in the data
120
+ * after the consensus is reached.
121
+ *
122
+ * ```typescript
123
+ * pactMap.on("pending", (key: string) => {
124
+ * console.log(pactMap.getPending(key));
125
+ * });
126
+ * pactMap.on("accepted", (key: string) => {
127
+ * console.log(pactMap.get(key));
128
+ * });
129
+ * pactMap.set("myKey", "myValue");
130
+ *
131
+ * // Reading from the pact map prior to the async operation's completion will still return the old value.
132
+ * console.log(pactMap.get("myKey"));
133
+ * ```
134
+ *
135
+ * The acceptance process has two stages. When an op indicating a client's attempt to set a value is sequenced,
136
+ * we first verify that it was set with knowledge of the most recently accepted value (consensus-like FWW). If it
137
+ * meets this bar, then the value is "pending". During this time, clients may observe the pending value and act
138
+ * upon it, but should be aware that not all other clients may have witnessed the value yet. Once all clients
139
+ * that were connected at the time of the value being set have explicitly acknowledged the new value, the value
140
+ * becomes "accepted". Once the value is accepted, it once again becomes possible to set the value, again with
141
+ * consensus-like FWW resolution.
142
+ *
143
+ * Since all connected clients must explicitly accept the new value, it is important that all connected clients
144
+ * have the PactMap loaded, including e.g. the summarizing client. Otherwise, those clients who have not loaded
145
+ * the PactMap will not be responding to proposals and delay their acceptance (until they disconnect, which implicitly
146
+ * removes them from consideration). The easiest way to ensure all clients load the PactMap is to instantiate it
147
+ * as part of instantiating the IRuntime for the container (containerHasInitialized if using Aqueduct).
148
+ *
149
+ * ### Eventing
150
+ *
151
+ * `PactMap` is an `EventEmitter`, and will emit events when a new value is accepted for a key.
152
+ *
153
+ * ```typescript
154
+ * pactMap.on("accept", (key: string) => {
155
+ * console.log(`New value was accepted for key: ${ key }, value: ${ pactMap.get(key) }`);
156
+ * });
157
+ * ```
158
+ */
159
+ export class PactMap<T = unknown> extends SharedObject<IPactMapEvents> implements IPactMap<T> {
160
+ /**
161
+ * Create a new PactMap
162
+ *
163
+ * @param runtime - data store runtime the new PactMap belongs to
164
+ * @param id - optional name of the PactMap
165
+ * @returns newly created PactMap (but not attached yet)
166
+ */
167
+ public static create(runtime: IFluidDataStoreRuntime, id?: string): PactMap {
168
+ return runtime.createChannel(id, PactMapFactory.Type) as PactMap;
169
+ }
170
+
171
+ /**
172
+ * Get a factory for PactMap to register with the data store.
173
+ *
174
+ * @returns a factory that creates and loads PactMaps
175
+ */
176
+ public static getFactory(): IChannelFactory {
177
+ return new PactMapFactory();
178
+ }
179
+
180
+ private readonly values: Map<string, Pact<T>> = new Map();
181
+
182
+ private readonly incomingOp: EventEmitter = new EventEmitter();
183
+
184
+ /**
185
+ * Constructs a new PactMap. If the object is non-local an id and service interfaces will
186
+ * be provided
187
+ *
188
+ * @param runtime - data store runtime the PactMap belongs to
189
+ * @param id - optional name of the PactMap
190
+ */
191
+ public constructor(
192
+ id: string,
193
+ runtime: IFluidDataStoreRuntime,
194
+ attributes: IChannelAttributes,
195
+ ) {
196
+ super(id, runtime, attributes, "fluid_pactMap_");
197
+
198
+ this.incomingOp.on("set", this.handleIncomingSet);
199
+ this.incomingOp.on("accept", this.handleIncomingAccept);
200
+
201
+ this.runtime.getQuorum().on("removeMember", this.handleQuorumRemoveMember);
202
+ }
203
+
204
+ /**
205
+ * {@inheritDoc IPactMap.get}
206
+ */
207
+ public get(key: string): T | undefined {
208
+ return this.values.get(key)?.accepted?.value;
209
+ }
210
+
211
+ /**
212
+ * {@inheritDoc IPactMap.isPending}
213
+ */
214
+ public isPending(key: string): boolean {
215
+ return this.values.get(key)?.pending !== undefined;
216
+ }
217
+
218
+ /**
219
+ * {@inheritDoc IPactMap.getPending}
220
+ */
221
+ public getPending(key: string): T | undefined {
222
+ return this.values.get(key)?.pending?.value;
223
+ }
224
+
225
+ /**
226
+ * {@inheritDoc IPactMap.set}
227
+ */
228
+ public set(key: string, value: T | undefined): void {
229
+ const currentValue = this.values.get(key);
230
+ // Early-exit if we can't submit a valid proposal (there's already a pending proposal)
231
+ if (currentValue?.pending !== undefined) {
232
+ return;
233
+ }
234
+
235
+ // If not attached, we basically pretend we got an ack immediately.
236
+ if (!this.isAttached()) {
237
+ // Queueing as a microtask to permit callers to complete their callstacks before the result of the set
238
+ // takes effect. This more closely resembles the pattern in the attached state, where the ack will not
239
+ // be received synchronously.
240
+ queueMicrotask(() => {
241
+ this.handleIncomingSet(key, value, 0 /* refSeq */, 0 /* setSequenceNumber */);
242
+ });
243
+ return;
244
+ }
245
+
246
+ const setOp: IPactMapSetOperation<T> = {
247
+ type: "set",
248
+ key,
249
+ value,
250
+ refSeq: this.runtime.deltaManager.lastSequenceNumber,
251
+ };
252
+
253
+ this.submitLocalMessage(setOp);
254
+ }
255
+
256
+ /**
257
+ * {@inheritDoc IPactMap.delete}
258
+ */
259
+ public delete(key: string): void {
260
+ const currentValue = this.values.get(key);
261
+ // Early-exit if:
262
+ if (
263
+ // there's nothing to delete
264
+ currentValue === undefined ||
265
+ // if something is pending (and so our proposal won't be valid)
266
+ currentValue.pending !== undefined ||
267
+ // or if the accepted value is undefined which is equivalent to already being deleted
268
+ currentValue.accepted.value === undefined
269
+ ) {
270
+ return;
271
+ }
272
+
273
+ this.set(key, undefined);
274
+ }
275
+
276
+ /**
277
+ * Get a point-in-time list of clients who must sign off on values coming in for them to move from "pending" to
278
+ * "accepted" state. This list is finalized for a value at the moment it goes pending (i.e. if more clients
279
+ * join later, they are not added to the list of signoffs).
280
+ * @returns The list of clientIds for clients who must sign off to accept the incoming pending value
281
+ */
282
+ private getSignoffClients(): string[] {
283
+ // If detached, we don't need anyone to sign off. Otherwise, we need all currently connected clients.
284
+ return this.isAttached() ? [...this.runtime.getQuorum().getMembers().keys()] : [];
285
+ }
286
+
287
+ private readonly handleIncomingSet = (
288
+ key: string,
289
+ value: T | undefined,
290
+ refSeq: number,
291
+ setSequenceNumber: number,
292
+ ): void => {
293
+ const currentValue = this.values.get(key);
294
+ // We use a consensus-like approach here, so a proposal is valid if the value is unset or if there is no
295
+ // pending change and it was made with knowledge of the most recently accepted value. We'll drop invalid
296
+ // proposals on the ground.
297
+ const proposalValid =
298
+ currentValue === undefined ||
299
+ (currentValue.pending === undefined && currentValue.accepted.sequenceNumber <= refSeq);
300
+ if (!proposalValid) {
301
+ return;
302
+ }
303
+
304
+ const accepted = currentValue?.accepted;
305
+
306
+ // We expect signoffs from all connected clients at the time the set was sequenced (including the client who
307
+ // sent the set).
308
+ const expectedSignoffs = this.getSignoffClients();
309
+
310
+ const newPact: Pact<T> = {
311
+ accepted,
312
+ pending: {
313
+ value,
314
+ expectedSignoffs,
315
+ },
316
+ };
317
+
318
+ this.values.set(key, newPact);
319
+
320
+ this.emit("pending", key);
321
+
322
+ if (expectedSignoffs.length === 0) {
323
+ // At least the submitting client should be amongst the expectedSignoffs, but keeping this check around
324
+ // as extra protection and in case we bring back the "submitting client implicitly accepts" optimization.
325
+ this.values.set(key, {
326
+ accepted: { value, sequenceNumber: setSequenceNumber },
327
+ pending: undefined,
328
+ });
329
+ this.emit("accepted", key);
330
+ } else if (
331
+ this.runtime.clientId !== undefined &&
332
+ expectedSignoffs.includes(this.runtime.clientId)
333
+ ) {
334
+ // Emit an accept upon a new key entering pending state if our accept is expected.
335
+ const acceptOp: IPactMapAcceptOperation = {
336
+ type: "accept",
337
+ key,
338
+ };
339
+ this.submitLocalMessage(acceptOp);
340
+ }
341
+ };
342
+
343
+ private readonly handleIncomingAccept = (
344
+ key: string,
345
+ clientId: string,
346
+ sequenceNumber: number,
347
+ ): void => {
348
+ const pending = this.values.get(key)?.pending;
349
+ // We don't resubmit accepts on reconnect so this should only run for expected accepts.
350
+ assert(pending !== undefined, 0x2f8 /* Unexpected accept op, nothing pending */);
351
+ assert(
352
+ pending.expectedSignoffs.includes(clientId),
353
+ 0x2f9 /* Unexpected accept op, client not in expectedSignoffs */,
354
+ );
355
+
356
+ // Remove the client from the expected signoffs
357
+ pending.expectedSignoffs = pending.expectedSignoffs.filter(
358
+ (expectedClientId) => expectedClientId !== clientId,
359
+ );
360
+
361
+ if (pending.expectedSignoffs.length === 0) {
362
+ // The pending value has settled
363
+ this.values.set(key, {
364
+ accepted: { value: pending.value, sequenceNumber },
365
+ pending: undefined,
366
+ });
367
+ this.emit("accepted", key);
368
+ }
369
+ };
370
+
371
+ private readonly handleQuorumRemoveMember = (clientId: string): void => {
372
+ for (const [key, { pending }] of this.values) {
373
+ if (pending !== undefined) {
374
+ pending.expectedSignoffs = pending.expectedSignoffs.filter(
375
+ (expectedClientId) => expectedClientId !== clientId,
376
+ );
377
+
378
+ if (pending.expectedSignoffs.length === 0) {
379
+ // The pending value has settled
380
+ this.values.set(key, {
381
+ accepted: {
382
+ value: pending.value,
383
+ // The sequence number of the ClientLeave message.
384
+ sequenceNumber: this.runtime.deltaManager.lastSequenceNumber,
385
+ },
386
+ pending: undefined,
387
+ });
388
+ this.emit("accepted", key);
389
+ }
390
+ }
391
+ }
392
+ };
393
+
394
+ /**
395
+ * Create a summary for the PactMap
396
+ *
397
+ * @returns the summary of the current state of the PactMap
398
+ * @internal
399
+ */
400
+ protected summarizeCore(serializer: IFluidSerializer): ISummaryTreeWithStats {
401
+ const allEntries = [...this.values.entries()];
402
+ // Filter out items that are ineffectual
403
+ const summaryEntries = allEntries.filter(([, pact]) => {
404
+ return (
405
+ // Items have an effect if they are still pending, have a real value, or some client may try to
406
+ // reference state before the value was accepted. Otherwise they can be dropped.
407
+ pact.pending !== undefined ||
408
+ pact.accepted.value !== undefined ||
409
+ pact.accepted.sequenceNumber > this.runtime.deltaManager.minimumSequenceNumber
410
+ );
411
+ });
412
+ return createSingleBlobSummary(snapshotFileName, JSON.stringify(summaryEntries));
413
+ }
414
+
415
+ /**
416
+ * {@inheritDoc @fluidframework/shared-object-base#SharedObject.loadCore}
417
+ * @internal
418
+ */
419
+ protected async loadCore(storage: IChannelStorageService): Promise<void> {
420
+ const content = await readAndParse<[string, Pact<T>][]>(storage, snapshotFileName);
421
+ for (const [key, value] of content) {
422
+ this.values.set(key, value);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * {@inheritDoc @fluidframework/shared-object-base#SharedObjectCore.initializeLocalCore}
428
+ * @internal
429
+ */
430
+ protected initializeLocalCore(): void {}
431
+
432
+ /**
433
+ * {@inheritDoc @fluidframework/shared-object-base#SharedObjectCore.onDisconnect}
434
+ * @internal
435
+ */
436
+ protected onDisconnect(): void {}
437
+
438
+ /**
439
+ * {@inheritDoc @fluidframework/shared-object-base#SharedObjectCore.reSubmitCore}
440
+ * @internal
441
+ */
442
+ protected reSubmitCore(content: unknown, localOpMetadata: unknown): void {
443
+ const pactMapOp = content as IPactMapOperation<T>;
444
+ // Filter out accept messages - if we're coming back from a disconnect, our acceptance is never required
445
+ // because we're implicitly removed from the list of expected accepts.
446
+ if (pactMapOp.type === "accept") {
447
+ return;
448
+ }
449
+
450
+ // Filter out set messages that have no chance of being accepted because there's another value pending
451
+ // or another value was accepted while we were disconnected.
452
+ const currentValue = this.values.get(pactMapOp.key);
453
+ if (
454
+ currentValue !== undefined &&
455
+ (currentValue.pending !== undefined ||
456
+ pactMapOp.refSeq < currentValue.accepted?.sequenceNumber)
457
+ ) {
458
+ return;
459
+ }
460
+
461
+ // Otherwise we can resubmit
462
+ this.submitLocalMessage(pactMapOp, localOpMetadata);
463
+ }
464
+
465
+ /**
466
+ * Process a PactMap operation
467
+ *
468
+ * @param message - the message to prepare
469
+ * @param local - whether the message was sent by the local client
470
+ * @param localOpMetadata - For local client messages, this is the metadata that was submitted with the message.
471
+ * For messages from a remote client, this will be undefined.
472
+ * @internal
473
+ */
474
+ protected processCore(
475
+ message: ISequencedDocumentMessage,
476
+ local: boolean,
477
+ localOpMetadata: unknown,
478
+ ): void {
479
+ if (message.type === MessageType.Operation) {
480
+ const op = message.contents as IPactMapOperation<T>;
481
+
482
+ switch (op.type) {
483
+ case "set":
484
+ this.incomingOp.emit(
485
+ "set",
486
+ op.key,
487
+ op.value,
488
+ op.refSeq,
489
+ message.sequenceNumber,
490
+ );
491
+ break;
492
+
493
+ case "accept":
494
+ this.incomingOp.emit(
495
+ "accept",
496
+ op.key,
497
+ message.clientId,
498
+ message.sequenceNumber,
499
+ );
500
+ break;
501
+
502
+ default:
503
+ throw new Error("Unknown operation");
504
+ }
505
+ }
506
+ }
507
+
508
+ public applyStashedOp(): void {
509
+ throw new Error("not implemented");
510
+ }
511
+ }