@milaboratories/pl-tree 1.3.6

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.
@@ -0,0 +1,101 @@
1
+ import { field, TestHelpers } from '@milaboratories/pl-client';
2
+ import { PlTreeState } from './state';
3
+ import { constructTreeLoadingRequest, loadTreeState } from './sync';
4
+ import { Computable } from '@milaboratories/computable';
5
+ import { TestStructuralResourceType1 } from './test_utils';
6
+
7
+ test('load resources', async () => {
8
+ await TestHelpers.withTempRoot(async (cl) => {
9
+ const r1 = await cl.withWriteTx(
10
+ 'CreatingStructure1',
11
+ async (tx) => {
12
+ const rr1 = tx.createStruct(TestStructuralResourceType1);
13
+ const ff1 = field(tx.clientRoot, 'f1');
14
+ tx.createField(ff1, 'Dynamic');
15
+ tx.setField(ff1, rr1);
16
+ await tx.commit();
17
+ return await rr1.globalId;
18
+ },
19
+ { sync: true }
20
+ );
21
+
22
+ const treeState = new PlTreeState(r1);
23
+
24
+ const theComputable = Computable.make((c) =>
25
+ c.accessor(treeState.entry()).node().traverse('a', 'b')?.getDataAsString()
26
+ );
27
+
28
+ const refreshState = async (): Promise<void> => {
29
+ const req = constructTreeLoadingRequest(treeState);
30
+ const states = await cl.withReadTx('loadingTree', (tx) => loadTreeState(tx, req));
31
+ treeState.updateFromResourceData(states);
32
+ };
33
+
34
+ await refreshState();
35
+
36
+ expect(await theComputable.getValueOrError()).toMatchObject({
37
+ stable: false,
38
+ value: undefined
39
+ });
40
+
41
+ const r2 = await cl.withWriteTx(
42
+ 'CreatingStructure2',
43
+ async (tx) => {
44
+ const rr2 = tx.createStruct(TestStructuralResourceType1);
45
+ const ff2 = field(r1, 'a');
46
+ tx.createField(ff2, 'Input');
47
+ tx.setField(ff2, rr2);
48
+ await tx.commit();
49
+ return await rr2.globalId;
50
+ },
51
+ { sync: true }
52
+ );
53
+
54
+ await refreshState();
55
+
56
+ expect(theComputable.isChanged()).toBe(true);
57
+ expect(await theComputable.getValueOrError()).toMatchObject({
58
+ stable: false,
59
+ value: undefined
60
+ });
61
+
62
+ const r3 = await cl.withWriteTx(
63
+ 'CreatingStructure3',
64
+ async (tx) => {
65
+ const rr3 = tx.createValue(TestStructuralResourceType1, 'hi!');
66
+ const ff3 = field(r2, 'b');
67
+ tx.createField(ff3, 'Input');
68
+ tx.setField(ff3, rr3);
69
+ await tx.commit();
70
+ return await rr3.globalId;
71
+ },
72
+ { sync: true }
73
+ );
74
+
75
+ await refreshState();
76
+
77
+ expect(theComputable.isChanged()).toBe(true);
78
+ expect(await theComputable.getValueOrError()).toMatchObject({
79
+ stable: true,
80
+ value: 'hi!'
81
+ });
82
+
83
+ await cl.withWriteTx(
84
+ 'CreatingStructure3',
85
+ async (tx) => {
86
+ tx.lock(r1);
87
+ tx.lock(r2);
88
+ await tx.commit();
89
+ },
90
+ { sync: true }
91
+ );
92
+
93
+ await refreshState();
94
+
95
+ expect(theComputable.isChanged()).toBe(true);
96
+ expect(await theComputable.getValueOrError()).toMatchObject({
97
+ stable: true,
98
+ value: 'hi!'
99
+ });
100
+ });
101
+ });
package/src/sync.ts ADDED
@@ -0,0 +1,129 @@
1
+ import {
2
+ FieldData,
3
+ isNullResourceId,
4
+ OptionalResourceId,
5
+ PlTransaction,
6
+ ResourceId
7
+ } from '@milaboratories/pl-client';
8
+ import Denque from 'denque';
9
+ import { ExtendedResourceData, PlTreeState } from './state';
10
+
11
+ /** Applied to list of fields in resource data. */
12
+ export type PruningFunction = (resource: ExtendedResourceData) => FieldData[];
13
+
14
+ export interface TreeLoadingRequest {
15
+ /** Resource to prime the traversal algorithm. It is ok, if some of them
16
+ * doesn't exist anymore. Should not contain elements from final resource
17
+ * set. */
18
+ readonly seedResources: ResourceId[];
19
+
20
+ /** Resource ids for which state is already known and not expected to change.
21
+ * Algorithm will not continue traversal over those ids, and states will not
22
+ * be retrieved for them. */
23
+ readonly finalResources: Set<ResourceId>;
24
+
25
+ /** This function is applied to each resource data field list, before
26
+ * using it continue traversal. This modification also is applied to
27
+ * output data to make result self-consistent in terms that it will contain
28
+ * all referenced resources, this is required to be able to pass it to tree
29
+ * to update the state. */
30
+ readonly pruningFunction?: PruningFunction;
31
+ }
32
+
33
+ /** Given the current tree state, build the request object to pass to
34
+ * {@link loadTreeState} to load updated state. */
35
+ export function constructTreeLoadingRequest(
36
+ tree: PlTreeState,
37
+ pruningFunction?: PruningFunction
38
+ ): TreeLoadingRequest {
39
+ const seedResources: ResourceId[] = [];
40
+ const finalResources = new Set<ResourceId>();
41
+ tree.forEachResource((res) => {
42
+ if (res.final) finalResources.add(res.id);
43
+ else seedResources.push(res.id);
44
+ });
45
+
46
+ // if tree is empty, seeding tree reconstruction from the specified root
47
+ if (seedResources.length === 0 && finalResources.size === 0) seedResources.push(tree.root);
48
+
49
+ return { seedResources, finalResources, pruningFunction };
50
+ }
51
+
52
+ /** Given the transaction (preferably read-only) and loading request, executes
53
+ * the tree traversal algorithm, and collects fresh states of resources
54
+ * to update the tree state. */
55
+ export async function loadTreeState(
56
+ tx: PlTransaction,
57
+ loadingRequest: TreeLoadingRequest
58
+ ): Promise<ExtendedResourceData[]> {
59
+ const { seedResources, finalResources, pruningFunction } = loadingRequest;
60
+
61
+ // Main idea of using a queue here is that responses will arrive in the same order as they were
62
+ // sent, so we can only wait for the earliest sent unprocessed response promise at any given moment.
63
+ // In such a way logic become linear without recursion, and at the same time deal with data
64
+ // as soon as it arrives.
65
+
66
+ const pending = new Denque<Promise<ExtendedResourceData | undefined>>();
67
+
68
+ // tracking resources we already requested
69
+ const requested = new Set<ResourceId>();
70
+ const requestState = (rid: OptionalResourceId) => {
71
+ if (isNullResourceId(rid) || requested.has(rid) || finalResources.has(rid)) return;
72
+
73
+ // adding the id, so we will not request it's state again if somebody else
74
+ // references the same resource
75
+ requested.add(rid);
76
+
77
+ // requesting resource and all kv records
78
+ const resourceData = tx.getResourceDataIfExists(rid, true);
79
+ const kvData = tx.listKeyValuesIfResourceExists(rid);
80
+
81
+ // pushing combined promise
82
+ pending.push(
83
+ (async () => {
84
+ const resource = await resourceData;
85
+ const kv = await kvData;
86
+
87
+ if (resource === undefined) return undefined;
88
+
89
+ if (kv === undefined) throw new Error('Inconsistent replies');
90
+
91
+ return { ...resource, kv };
92
+ })()
93
+ );
94
+ };
95
+
96
+ // sending seed requests
97
+ seedResources.forEach((rid) => requestState(rid));
98
+
99
+ const result: ExtendedResourceData[] = [];
100
+ while (true) {
101
+ // taking next pending request
102
+ const nextResourcePromise = pending.shift();
103
+ if (nextResourcePromise === undefined)
104
+ // this means we have no pending requests and traversal is over
105
+ break;
106
+
107
+ // at this point we pause and wait for the nest requested resource state to arrive
108
+ let nextResource = await nextResourcePromise;
109
+ if (nextResource === undefined)
110
+ // ignoring resources that were not found (this may happen for seed resource ids)
111
+ continue;
112
+
113
+ // apply field pruning, if requested
114
+ if (pruningFunction !== undefined)
115
+ nextResource = { ...nextResource, fields: pruningFunction(nextResource) };
116
+
117
+ // continue traversal over the referenced resource
118
+ requestState(nextResource.error);
119
+ for (const field of nextResource.fields) {
120
+ requestState(field.value);
121
+ requestState(field.error);
122
+ }
123
+
124
+ // aggregating the state
125
+ result.push(nextResource);
126
+ }
127
+
128
+ return result;
129
+ }
@@ -0,0 +1,210 @@
1
+ import { field, TestHelpers } from '@milaboratories/pl-client';
2
+ import { TestStructuralResourceType1 } from './test_utils';
3
+ import { Computable } from '@milaboratories/computable';
4
+ import { SynchronizedTreeState } from './synchronized_tree';
5
+
6
+ test('simple synchronized tree test', async () => {
7
+ await TestHelpers.withTempRoot(async (pl) => {
8
+ const r1 = await pl.withWriteTx(
9
+ 'CreatingStructure1',
10
+ async (tx) => {
11
+ const rr1 = tx.createStruct(TestStructuralResourceType1);
12
+ const ff1 = field(tx.clientRoot, 'f1');
13
+ tx.createField(ff1, 'Dynamic');
14
+ tx.setField(ff1, rr1);
15
+ await tx.commit();
16
+ return await rr1.globalId;
17
+ },
18
+ { sync: true }
19
+ );
20
+
21
+ const treeState = await SynchronizedTreeState.init(pl, r1, {
22
+ stopPollingDelay: 10,
23
+ pollingInterval: 10
24
+ });
25
+
26
+ const theComputable = Computable.make((c) =>
27
+ c.accessor(treeState.entry()).node().traverse('a', 'b')?.getDataAsString()
28
+ );
29
+
30
+ await theComputable.refreshState();
31
+
32
+ expect(await theComputable.getValueOrError()).toMatchObject({
33
+ stable: false,
34
+ value: undefined
35
+ });
36
+
37
+ const r2 = await pl.withWriteTx(
38
+ 'CreatingStructure2',
39
+ async (tx) => {
40
+ const rr2 = tx.createStruct(TestStructuralResourceType1);
41
+ const ff2 = field(r1, 'a');
42
+ tx.createField(ff2, 'Input');
43
+ tx.setField(ff2, rr2);
44
+ await tx.commit();
45
+ return await rr2.globalId;
46
+ },
47
+ { sync: true }
48
+ );
49
+
50
+ await theComputable.refreshState();
51
+
52
+ expect(theComputable.isChanged()).toBe(true);
53
+ expect(await theComputable.getValueOrError()).toMatchObject({
54
+ stable: false,
55
+ value: undefined
56
+ });
57
+
58
+ const r3 = await pl.withWriteTx(
59
+ 'CreatingStructure3',
60
+ async (tx) => {
61
+ const rr3 = tx.createValue(TestStructuralResourceType1, 'hi!');
62
+ const ff3 = field(r2, 'b');
63
+ tx.createField(ff3, 'Input');
64
+ tx.setField(ff3, rr3);
65
+ await tx.commit();
66
+ return await rr3.globalId;
67
+ },
68
+ { sync: true }
69
+ );
70
+
71
+ await theComputable.refreshState();
72
+
73
+ expect(theComputable.isChanged()).toBe(true);
74
+ expect(await theComputable.getValueOrError()).toMatchObject({
75
+ stable: true,
76
+ value: 'hi!'
77
+ });
78
+
79
+ await pl.withWriteTx(
80
+ 'CreatingStructure3',
81
+ async (tx) => {
82
+ tx.lock(r1);
83
+ tx.lock(r2);
84
+ await tx.commit();
85
+ },
86
+ { sync: true }
87
+ );
88
+
89
+ await theComputable.refreshState();
90
+
91
+ expect(theComputable.isChanged()).toBe(true);
92
+ expect(await theComputable.getValueOrError()).toMatchObject({
93
+ stable: true,
94
+ value: 'hi!'
95
+ });
96
+
97
+ await treeState.awaitSyncLoopTermination();
98
+ });
99
+ });
100
+
101
+ test('synchronized tree test with KV', async () => {
102
+ await TestHelpers.withTempRoot(async (pl) => {
103
+ const r1 = await pl.withWriteTx(
104
+ 'CreatingStructure1',
105
+ async (tx) => {
106
+ const rr1 = tx.createStruct(TestStructuralResourceType1);
107
+ const ff1 = field(tx.clientRoot, 'f1');
108
+ tx.createField(ff1, 'Dynamic');
109
+ tx.setField(ff1, rr1);
110
+ await tx.commit();
111
+ return await rr1.globalId;
112
+ },
113
+ { sync: true }
114
+ );
115
+
116
+ const treeState = await SynchronizedTreeState.init(pl, r1, {
117
+ stopPollingDelay: 10,
118
+ pollingInterval: 10
119
+ });
120
+
121
+ const theComputable = Computable.make((c) =>
122
+ c.accessor(treeState.entry()).node().traverse('a')?.getKeyValueAsString('b', true)
123
+ );
124
+
125
+ await theComputable.refreshState();
126
+
127
+ expect(await theComputable.getValueOrError()).toMatchObject({
128
+ stable: false,
129
+ value: undefined
130
+ });
131
+
132
+ const r2 = await pl.withWriteTx(
133
+ 'CreatingStructure2',
134
+ async (tx) => {
135
+ const rr2 = tx.createStruct(TestStructuralResourceType1);
136
+ const ff2 = field(r1, 'a');
137
+ tx.createField(ff2, 'Input');
138
+ tx.setField(ff2, rr2);
139
+ await tx.commit();
140
+ return await rr2.globalId;
141
+ },
142
+ { sync: true }
143
+ );
144
+
145
+ await theComputable.refreshState();
146
+
147
+ expect(theComputable.isChanged()).toBe(true);
148
+ expect(await theComputable.getValueOrError()).toMatchObject({
149
+ stable: false,
150
+ value: undefined
151
+ });
152
+
153
+ await pl.withWriteTx('AssignKeyValue', async (tx) => {
154
+ tx.setKValue(r2, 'b', 'hi!');
155
+ await tx.commit();
156
+ });
157
+
158
+ await theComputable.refreshState();
159
+
160
+ expect(theComputable.isChanged()).toBe(true);
161
+ expect(await theComputable.getValueOrError()).toMatchObject({
162
+ stable: true,
163
+ value: 'hi!'
164
+ });
165
+
166
+ await treeState.awaitSyncLoopTermination();
167
+ });
168
+ });
169
+
170
+ test('termination test', async () => {
171
+ await TestHelpers.withTempRoot(async (pl) => {
172
+ const r1 = await pl.withWriteTx(
173
+ 'CreatingStructure1',
174
+ async (tx) => {
175
+ const rr1 = tx.createStruct(TestStructuralResourceType1);
176
+ const ff1 = field(tx.clientRoot, 'f1');
177
+ tx.createField(ff1, 'Dynamic');
178
+ tx.setField(ff1, rr1);
179
+ await tx.commit();
180
+ return await rr1.globalId;
181
+ },
182
+ { sync: true }
183
+ );
184
+
185
+ const treeState = await SynchronizedTreeState.init(pl, r1, {
186
+ stopPollingDelay: 10,
187
+ pollingInterval: 10
188
+ });
189
+
190
+ const entry = treeState.entry();
191
+ const theComputable = Computable.make((c) =>
192
+ c.accessor(entry).node().traverse('a')?.getKeyValueAsString('b', true)
193
+ );
194
+
195
+ await theComputable.refreshState();
196
+
197
+ expect(await theComputable.getValueOrError()).toMatchObject({
198
+ stable: false,
199
+ value: undefined
200
+ });
201
+
202
+ await treeState.terminate();
203
+
204
+ const resultAfterTermination = await theComputable.getValueOrError();
205
+ expect(resultAfterTermination).toMatchObject({
206
+ type: 'error'
207
+ });
208
+ expect((resultAfterTermination as any).errors[0].message).toMatch('terminated');
209
+ });
210
+ });
@@ -0,0 +1,189 @@
1
+ import { PollingComputableHooks } from '@milaboratories/computable';
2
+ import { PlTreeEntry } from './accessors';
3
+ import { isTimeoutOrCancelError, PlClient, ResourceId } from '@milaboratories/pl-client';
4
+ import { FinalPredicate, PlTreeState, TreeStateUpdateError } from './state';
5
+ import { constructTreeLoadingRequest, loadTreeState, PruningFunction } from './sync';
6
+ import * as tp from 'node:timers/promises';
7
+ import { MiLogger } from '@milaboratories/ts-helpers';
8
+
9
+ export type SynchronizedTreeOps = {
10
+ finalPredicate?: FinalPredicate;
11
+ pruning?: PruningFunction;
12
+
13
+ /** Interval after last sync to sleep before the next one */
14
+ pollingInterval: number;
15
+ /** For how long to continue polling after the last derived value access */
16
+ stopPollingDelay: number;
17
+ };
18
+
19
+ type ScheduledRefresh = {
20
+ resolve: () => void;
21
+ reject: (err: any) => void;
22
+ };
23
+
24
+ export class SynchronizedTreeState {
25
+ private readonly finalPredicate: FinalPredicate | undefined;
26
+ private state: PlTreeState;
27
+ private readonly pollingInterval: number;
28
+ private readonly pruning?: PruningFunction;
29
+ private readonly hooks: PollingComputableHooks;
30
+ private readonly abortController = new AbortController();
31
+
32
+ private constructor(
33
+ private readonly pl: PlClient,
34
+ private readonly root: ResourceId,
35
+ ops: SynchronizedTreeOps,
36
+ private readonly logger?: MiLogger
37
+ ) {
38
+ const { finalPredicate, pruning, pollingInterval, stopPollingDelay } = ops;
39
+ this.pruning = pruning;
40
+ this.pollingInterval = pollingInterval;
41
+ this.finalPredicate = finalPredicate;
42
+ this.state = new PlTreeState(root, finalPredicate);
43
+ this.hooks = new PollingComputableHooks(
44
+ () => this.startUpdating(),
45
+ () => this.stopUpdating(),
46
+ { stopDebounce: stopPollingDelay },
47
+ (resolve, reject) => this.scheduleOnNextState(resolve, reject)
48
+ );
49
+ }
50
+
51
+ /** @deprecated use "entry" instead */
52
+ public accessor(rid: ResourceId = this.root): PlTreeEntry {
53
+ if (this.terminated) throw new Error('tree synchronization is terminated');
54
+ return this.entry(rid);
55
+ }
56
+
57
+ public entry(rid: ResourceId = this.root): PlTreeEntry {
58
+ if (this.terminated) throw new Error('tree synchronization is terminated');
59
+ return new PlTreeEntry({ treeProvider: () => this.state, hooks: this.hooks }, rid);
60
+ }
61
+
62
+ /** Can be used to externally kick off the synchronization polling loop, and
63
+ * await for the first synchronization to happen. */
64
+ public async refreshState(): Promise<void> {
65
+ if (this.terminated) throw new Error('tree synchronization is terminated');
66
+ await this.hooks.refreshState();
67
+ }
68
+
69
+ private scheduledOnNextState: ScheduledRefresh[] = [];
70
+
71
+ private scheduleOnNextState(resolve: () => void, reject: (err: any) => void): void {
72
+ if (this.terminated) reject(new Error('tree synchronization is terminated'));
73
+ else this.scheduledOnNextState.push({ resolve, reject });
74
+ }
75
+
76
+ /** Called from observer */
77
+ private startUpdating(): void {
78
+ if (this.terminated) return;
79
+ this.keepRunning = true;
80
+ if (this.currentLoop === undefined) this.currentLoop = this.mainLoop();
81
+ }
82
+
83
+ /** Called from observer */
84
+ private stopUpdating(): void {
85
+ this.keepRunning = false;
86
+ }
87
+
88
+ /** If true, main loop will continue polling pl state. */
89
+ private keepRunning = false;
90
+ /** Actual state of main loop. */
91
+ private currentLoop: Promise<void> | undefined = undefined;
92
+
93
+ /** Executed from the main loop, and initialization procedure. */
94
+ private async refresh(): Promise<void> {
95
+ if (this.terminated) throw new Error('tree synchronization is terminated');
96
+ const request = constructTreeLoadingRequest(this.state, this.pruning);
97
+ const data = await this.pl.withReadTx('ReadingTree', async (tx) => {
98
+ return await loadTreeState(tx, request);
99
+ });
100
+ this.state.updateFromResourceData(data, true);
101
+ }
102
+
103
+ /** If true this tree state is permanently terminaed. */
104
+ private terminated = false;
105
+
106
+ private async mainLoop() {
107
+ while (true) {
108
+ if (!this.keepRunning) break;
109
+
110
+ // saving those who want to be notified about new state here
111
+ // because those who will be added during the tree retrieval
112
+ // should be notified only on the next round
113
+ let toNotify: ScheduledRefresh[] | undefined = undefined;
114
+ if (this.scheduledOnNextState.length > 0) {
115
+ toNotify = this.scheduledOnNextState;
116
+ this.scheduledOnNextState = [];
117
+ }
118
+
119
+ try {
120
+ // actual tree synchronization
121
+ await this.refresh();
122
+
123
+ // notifying that we got new state
124
+ if (toNotify !== undefined) for (const n of toNotify) n.resolve();
125
+ } catch (e: any) {
126
+ // notifying that we failed to refresh the state
127
+ if (toNotify !== undefined) for (const n of toNotify) n.reject(e);
128
+
129
+ // catching tree update errors, as they may leave our tree in inconsistent state
130
+ if (e instanceof TreeStateUpdateError) {
131
+ // important error logging, this should never happen
132
+ this.logger?.error(e);
133
+
134
+ // marking everybody who used previous state as changed
135
+ this.state.invalidateTree('stat update error');
136
+ // creating new tree
137
+ this.state = new PlTreeState(this.root, this.finalPredicate);
138
+
139
+ // scheduling state update without delay
140
+ continue;
141
+
142
+ // unfortunately external observer may still see tree in its default
143
+ // empty state, though this is best we can do in this exceptional
144
+ // situation, and hope on caching layers inside computables to present
145
+ // some stale state until we reconstruct the tree again
146
+ } else this.logger?.warn(e);
147
+ }
148
+
149
+ if (!this.keepRunning || this.terminated) break;
150
+
151
+ try {
152
+ await tp.setTimeout(this.pollingInterval, this.abortController.signal);
153
+ } catch (e: unknown) {
154
+ if (!isTimeoutOrCancelError(e)) throw new Error('Unexpected error', { cause: e });
155
+ break;
156
+ }
157
+ }
158
+
159
+ // reset only as a very last line
160
+ this.currentLoop = undefined;
161
+ }
162
+
163
+ /**
164
+ * Terminates the internal loop, and permanently destoys all internal state, so
165
+ * all computables using this state will resolve to errors.
166
+ * */
167
+ public async terminate(): Promise<void> {
168
+ this.keepRunning = false;
169
+ this.terminated = true;
170
+ this.abortController.abort();
171
+
172
+ if (this.currentLoop === undefined) return;
173
+ await this.currentLoop;
174
+
175
+ this.state.invalidateTree('synchronization terminated for the tree');
176
+ }
177
+
178
+ /** @deprecated */
179
+ public async awaitSyncLoopTermination(): Promise<void> {
180
+ if (this.currentLoop === undefined) return;
181
+ await this.currentLoop;
182
+ }
183
+
184
+ public static async init(pl: PlClient, root: ResourceId, ops: SynchronizedTreeOps) {
185
+ const tree = new SynchronizedTreeState(pl, root, ops);
186
+ await tree.refresh();
187
+ return tree;
188
+ }
189
+ }