@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.
- package/dist/index.cjs +1023 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +979 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
- package/src/accessors.ts +422 -0
- package/src/index.ts +8 -0
- package/src/snapshot.test.ts +101 -0
- package/src/snapshot.ts +219 -0
- package/src/state.test.ts +316 -0
- package/src/state.ts +681 -0
- package/src/sync.test.ts +101 -0
- package/src/sync.ts +129 -0
- package/src/synchronized_tree.test.ts +210 -0
- package/src/synchronized_tree.ts +189 -0
- package/src/test_utils.ts +156 -0
- package/src/traversal_ops.ts +56 -0
- package/src/value_and_error.ts +19 -0
- package/src/value_or_error.ts +9 -0
package/src/sync.test.ts
ADDED
|
@@ -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
|
+
}
|