querysub 0.428.0 → 0.430.0
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/package.json +1 -1
- package/src/-h-path-value-serialize/PathValueSerializer.ts +78 -1
- package/src/0-path-value-core/AuthorityLookup.ts +2 -2
- package/src/0-path-value-core/PathRouter.ts +5 -3
- package/src/0-path-value-core/PathValueController.ts +7 -1
- package/src/0-path-value-core/pathValueCore.ts +44 -15
- package/src/1-path-client/RemoteWatcher.ts +13 -1
- package/src/1-path-client/pathValueClientWatcher.ts +13 -1
- package/src/2-proxy/PathValueProxyWatcher.ts +28 -0
- package/src/2-proxy/TransactionDelayer.ts +95 -0
- package/src/4-querysub/querysubPrediction.ts +1 -1
- package/src/config.ts +5 -0
- package/src/diagnostics/SyncTestPage.tsx +105 -9
package/package.json
CHANGED
|
@@ -78,6 +78,8 @@ interface DataSettings {
|
|
|
78
78
|
undefined
|
|
79
79
|
// Split strings into multiple buffers
|
|
80
80
|
| 1
|
|
81
|
+
// Add transactionPaths support
|
|
82
|
+
| 2
|
|
81
83
|
);
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -92,6 +94,7 @@ interface PathValueStructure {
|
|
|
92
94
|
source?: string;
|
|
93
95
|
lockCount: number;
|
|
94
96
|
updateCount: number;
|
|
97
|
+
transactionPaths?: string[];
|
|
95
98
|
// IMPORTANT! All of these values must be explicitly copied to PathValue
|
|
96
99
|
// (we don't spread it), so if you add a new value here, make sure to
|
|
97
100
|
// update places that use these values (search for canGCValue in this file).
|
|
@@ -264,6 +267,69 @@ class PathValueSerializer {
|
|
|
264
267
|
return readLocks;
|
|
265
268
|
}
|
|
266
269
|
|
|
270
|
+
@measureFnc
|
|
271
|
+
private transactionPathsWrite(writer: Writer, transactionPaths: (string[] | undefined)[]) {
|
|
272
|
+
let transactionPathsIndexes = new Map<string[] | undefined, number>();
|
|
273
|
+
for (let pathArray of transactionPaths) {
|
|
274
|
+
if (transactionPathsIndexes.has(pathArray)) continue;
|
|
275
|
+
if (!pathArray || pathArray.length === 0) {
|
|
276
|
+
transactionPathsIndexes.set(pathArray, -1);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
transactionPathsIndexes.set(pathArray, transactionPathsIndexes.size);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let uniquePathArrays = Array.from(transactionPathsIndexes.keys());
|
|
283
|
+
writer.writeFloat64(uniquePathArrays.length);
|
|
284
|
+
for (let pathArray of uniquePathArrays) {
|
|
285
|
+
if (!pathArray || pathArray.length === 0) {
|
|
286
|
+
writer.writeFloat64(0);
|
|
287
|
+
} else {
|
|
288
|
+
writer.writeFloat64(pathArray.length);
|
|
289
|
+
for (let pathHash of pathArray) {
|
|
290
|
+
writer.writeString(pathHash);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let indexes = transactionPaths.map(x => transactionPathsIndexes.get(x)!);
|
|
296
|
+
writer.writeBuffer(asBuffer(new Int32Array(indexes)));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
@measureFnc
|
|
300
|
+
private transactionPathsRead(reader: Reader): (string[] | undefined)[] {
|
|
301
|
+
let uniquePathArrayCount = reader.readFloat64();
|
|
302
|
+
let uniquePathArrays: (string[] | undefined)[] = [];
|
|
303
|
+
for (let i = 0; i < uniquePathArrayCount; i++) {
|
|
304
|
+
let pathCount = reader.readFloat64();
|
|
305
|
+
if (pathCount === 0) {
|
|
306
|
+
uniquePathArrays.push(undefined);
|
|
307
|
+
} else {
|
|
308
|
+
let paths: string[] = [];
|
|
309
|
+
for (let j = 0; j < pathCount; j++) {
|
|
310
|
+
paths.push(reader.readString());
|
|
311
|
+
}
|
|
312
|
+
uniquePathArrays.push(paths);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let indexesBuf = reader.readBuffer();
|
|
317
|
+
let indexes: number[] = [];
|
|
318
|
+
for (let i = 0; i < indexesBuf.length; i += 4) {
|
|
319
|
+
indexes.push(indexesBuf.readInt32LE(i));
|
|
320
|
+
}
|
|
321
|
+
let transactionPaths = Array.from(indexes).map(index => {
|
|
322
|
+
if (index === -1) return undefined;
|
|
323
|
+
if (index < 0 || index >= uniquePathArrays.length) {
|
|
324
|
+
debugbreak(2);
|
|
325
|
+
debugger;
|
|
326
|
+
throw new Error(`Invalid transaction paths index ${index}, expected 0 <= index < ${uniquePathArrays.length}`);
|
|
327
|
+
}
|
|
328
|
+
return uniquePathArrays[index];
|
|
329
|
+
});
|
|
330
|
+
return transactionPaths;
|
|
331
|
+
}
|
|
332
|
+
|
|
267
333
|
|
|
268
334
|
private async valuesWrite(writer: Writer, valuesGroups: PathValue[][]) {
|
|
269
335
|
let totalLength = valuesGroups.reduce((total, x) => total + x.length, 0);
|
|
@@ -389,7 +455,7 @@ class PathValueSerializer {
|
|
|
389
455
|
singleBuffer?: boolean;
|
|
390
456
|
stripSource?: boolean;
|
|
391
457
|
}): Promise<Buffer[]> {
|
|
392
|
-
const version =
|
|
458
|
+
const version = 2;
|
|
393
459
|
let settings: DataSettings = {
|
|
394
460
|
valueCount: values.length,
|
|
395
461
|
noLocks: config?.noLocks,
|
|
@@ -479,6 +545,7 @@ class PathValueSerializer {
|
|
|
479
545
|
// TODO: Support breaking locks up
|
|
480
546
|
this.readLocksWrite(writer, values.map(x => x.locks));
|
|
481
547
|
}
|
|
548
|
+
this.transactionPathsWrite(writer, values.map(x => x.transactionPaths));
|
|
482
549
|
await this.valuesWrite(writer, valueGroups);
|
|
483
550
|
|
|
484
551
|
if (currentBufferPos > 0) {
|
|
@@ -560,6 +627,8 @@ class PathValueSerializer {
|
|
|
560
627
|
// All values become undefined. If also skipping strings OR if you have large values,
|
|
561
628
|
// this can make deserialization significantly faster.
|
|
562
629
|
skipValues?: boolean;
|
|
630
|
+
// Skip deserializing transaction paths.
|
|
631
|
+
skipTransactionPaths?: boolean;
|
|
563
632
|
}): Promise<PathValue[]> {
|
|
564
633
|
buffers = buffers.slice();
|
|
565
634
|
|
|
@@ -684,6 +753,13 @@ class PathValueSerializer {
|
|
|
684
753
|
let partialValues = this.pathValuesRead(reader, settings.valueCount);
|
|
685
754
|
let readLocks = settings.noLocks ? Array(settings.valueCount).fill([]) : this.readLocksRead(reader);
|
|
686
755
|
|
|
756
|
+
let transactionPaths: (string[] | undefined)[] = [];
|
|
757
|
+
// Skip transactions if they at all seem like they want to skip anything, as transactions are not very important. And how are you going to use transaction paths if you don't have values or if you don't have strings?
|
|
758
|
+
// - Oh, but I think skip values just makes the values lazy. So that's normal. We still want transactions even if the values are lazy.
|
|
759
|
+
if (version > 1 && !config?.skipStrings && !config?.skipTransactionPaths) {
|
|
760
|
+
transactionPaths = this.transactionPathsRead(reader);
|
|
761
|
+
}
|
|
762
|
+
|
|
687
763
|
let values: unknown[] = [];
|
|
688
764
|
let valuesAreLazy: boolean[] = [];
|
|
689
765
|
if (!config?.skipValues) {
|
|
@@ -710,6 +786,7 @@ class PathValueSerializer {
|
|
|
710
786
|
source: partialValue.source,
|
|
711
787
|
lockCount: partialValue.lockCount,
|
|
712
788
|
updateCount: partialValue.updateCount,
|
|
789
|
+
transactionPaths: transactionPaths[i],
|
|
713
790
|
});
|
|
714
791
|
}
|
|
715
792
|
|
|
@@ -18,8 +18,8 @@ import { getAllAuthoritySpec, getEmptyAuthoritySpec } from "./PathRouterServerAu
|
|
|
18
18
|
|
|
19
19
|
setImmediate(() => import("../3-path-functions/syncSchema"));
|
|
20
20
|
|
|
21
|
-
let NETWORK_POLL_INTERVAL = timeInMinute *
|
|
22
|
-
let CALL_TIMEOUT =
|
|
21
|
+
let NETWORK_POLL_INTERVAL = timeInMinute * 1;
|
|
22
|
+
let CALL_TIMEOUT = timeInSecond * 5;
|
|
23
23
|
|
|
24
24
|
export type AuthorityEntry = {
|
|
25
25
|
nodeId: string;
|
|
@@ -11,6 +11,7 @@ import { getRoutingOverride, hasPrefixHash } from "./PathRouterRouteOverride";
|
|
|
11
11
|
import { sha256 } from "js-sha256";
|
|
12
12
|
import { rangesOverlap, removeRange } from "../rangeMath";
|
|
13
13
|
import { decodeParentFilter } from "./hackedPackedPathParentFiltering";
|
|
14
|
+
import { getBufferInt } from "../bits";
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
// Cases
|
|
@@ -190,7 +191,9 @@ export class PathRouter {
|
|
|
190
191
|
this.lastKeyRoute.route = override.route;
|
|
191
192
|
return override.route;
|
|
192
193
|
}
|
|
193
|
-
let hash = fastHash(key);
|
|
194
|
+
//let hash = fastHash(key);
|
|
195
|
+
// NOTE: Having an even distribution is more important than hashing quickly, and it makes testing a lot easier.
|
|
196
|
+
let hash = getBufferInt(Buffer.from(sha256(key), "hex"));
|
|
194
197
|
let route = hash % (1000 * 1000 * 1000) / (1000 * 1000 * 1000);
|
|
195
198
|
this.lastKeyRoute.key = key;
|
|
196
199
|
this.lastKeyRoute.route = route;
|
|
@@ -636,8 +639,7 @@ export class PathRouter {
|
|
|
636
639
|
sort(fullPathMatches, x => preferredNodeIds.has(x.nodeId) ? -1 : 1);
|
|
637
640
|
sort(allSources, x => isOwnNodeId(x.nodeId) ? -1 : 1);
|
|
638
641
|
let missingRanges: { start: number; end: number }[] = [{
|
|
639
|
-
|
|
640
|
-
end: 1,
|
|
642
|
+
...parentRange
|
|
641
643
|
}];
|
|
642
644
|
let usedParts: {
|
|
643
645
|
nodeId: string;
|
|
@@ -9,7 +9,7 @@ import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSeriali
|
|
|
9
9
|
import { pathValueCommitter } from "./PathValueCommitter";
|
|
10
10
|
import { auditLog, isDebugLogEnabled } from "./auditLogs";
|
|
11
11
|
import { debugNodeId, debugNodeThread } from "../-c-identity/IdentityController";
|
|
12
|
-
import { isDiskAudit } from "../config";
|
|
12
|
+
import { getSlowdown, isDiskAudit } from "../config";
|
|
13
13
|
import { decodeNodeId } from "../-a-auth/certs";
|
|
14
14
|
import { areNodeIdsEqual, isOwnNodeId } from "../-f-node-discovery/NodeDiscovery";
|
|
15
15
|
import { getNodeIdIP } from "socket-function/src/nodeCache";
|
|
@@ -20,6 +20,7 @@ import { LZ4 } from "../storage/LZ4";
|
|
|
20
20
|
import { Time } from "./pathValueCore";
|
|
21
21
|
import { encodeCborx, decodeCborx } from "../misc/cloneHelpers";
|
|
22
22
|
import { debugGetAllCallFactories } from "socket-function/src/nodeCache";
|
|
23
|
+
import { delay } from "socket-function/src/batching";
|
|
23
24
|
export { pathValueCommitter };
|
|
24
25
|
|
|
25
26
|
// ONLY returns non-canGCValues that are valid
|
|
@@ -127,6 +128,11 @@ export class PathValueControllerBase {
|
|
|
127
128
|
let callerId = SocketFunction.getCaller().nodeId;
|
|
128
129
|
let { initialCreation, valueBuffers } = config;
|
|
129
130
|
|
|
131
|
+
let slowdown = getSlowdown();
|
|
132
|
+
if (slowdown) {
|
|
133
|
+
await delay(slowdown);
|
|
134
|
+
}
|
|
135
|
+
|
|
130
136
|
let values: PathValue[] = [];
|
|
131
137
|
if (valueBuffers) {
|
|
132
138
|
values = await pathValueSerializer.deserialize(valueBuffers);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
|
-
import { lazy } from "socket-function/src/caching";
|
|
2
|
+
import { cacheLimited, lazy } from "socket-function/src/caching";
|
|
3
3
|
import { addEpsilons, minusEpsilon } from "../bits";
|
|
4
4
|
import { logErrors } from "../errors";
|
|
5
5
|
import { appendToPathStr, getParentPathStr, getPathDepth, getPathIndexAssert, hack_getPackedPathSuffix, hack_setPackedPathSuffix, hack_stripPackedPath } from "../path";
|
|
@@ -26,6 +26,8 @@ import { onPathInteracted } from "../diagnostics/pathAuditerCallback";
|
|
|
26
26
|
import { decodeParentFilter, filterChildPathsBase } from "./hackedPackedPathParentFiltering";
|
|
27
27
|
import { isDiskAudit } from "../config";
|
|
28
28
|
import { removeRange } from "../rangeMath";
|
|
29
|
+
import { remoteWatcher } from "../1-path-client/RemoteWatcher";
|
|
30
|
+
import { sha256 } from "js-sha256";
|
|
29
31
|
|
|
30
32
|
setImmediate(async () => {
|
|
31
33
|
// Import everything will dynamically import, so the client side can tell that it's required.
|
|
@@ -291,6 +293,10 @@ export type PathValue = {
|
|
|
291
293
|
/** Used internally by archive management services. */
|
|
292
294
|
updateCount?: number;
|
|
293
295
|
|
|
296
|
+
/** This is the hashes of all the paths that were written at the same time, as in that have the same time as this path value.
|
|
297
|
+
- By using this, you can determine if you've only partially read a transaction by looking at the paths which you have read and comparing them against these path values. And if you see that some of them have a time equal to our time, but some of them have a time before our time, it means that you are missing part of our transaction. This allows the client to wait for the rest of the transaction to be received. */
|
|
298
|
+
transactionPaths?: string[];
|
|
299
|
+
|
|
294
300
|
// #endregion
|
|
295
301
|
};
|
|
296
302
|
|
|
@@ -374,6 +380,10 @@ export function getLockHash(lock: ReadLock) {
|
|
|
374
380
|
return timeHash(lock.startTime) + "_" + timeHash(lock.endTime) + "_" + lock.path;
|
|
375
381
|
}
|
|
376
382
|
|
|
383
|
+
export const hashPathForTransaction = cacheLimited(1000 * 1000, function hashPathForTransaction(path: string): string {
|
|
384
|
+
return Buffer.from(sha256(path), "hex").toString("base64").slice(0, 8);
|
|
385
|
+
});
|
|
386
|
+
|
|
377
387
|
export function debugPathValuePath(pathValue: { time: Time; path: string }): string {
|
|
378
388
|
// Log the raw path, so it can show up in searches
|
|
379
389
|
return `(${debugTime(pathValue.time)}) ${pathValue.path}`;
|
|
@@ -582,7 +592,7 @@ class AuthorityPathValueStorage {
|
|
|
582
592
|
}
|
|
583
593
|
return true;
|
|
584
594
|
}
|
|
585
|
-
public isSynced(path: string
|
|
595
|
+
public isSynced(path: string) {
|
|
586
596
|
if (PathRouter.isSelfAuthority(path)) return true;
|
|
587
597
|
|
|
588
598
|
if (this.isSyncedCache.has(path)) return true;
|
|
@@ -597,24 +607,42 @@ class AuthorityPathValueStorage {
|
|
|
597
607
|
return true;
|
|
598
608
|
}
|
|
599
609
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
610
|
+
{
|
|
611
|
+
// We are always going to prefer our own route. So if we can hash with our own route and match our route range, then it's going to be picked 100% of the time. And so it's always going to be synced.
|
|
612
|
+
let ourSpec = authorityLookup.getOurSpec();
|
|
613
|
+
let ourRoute = PathRouter.getRouteFull({ path, spec: ourSpec });
|
|
614
|
+
if (ourSpec.routeStart <= ourRoute && ourRoute < ourSpec.routeEnd) {
|
|
615
|
+
this.isSyncedCache.add(path);
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let remoteWatchRoute = remoteWatcher.getRemoteParentWatchRoute(path);
|
|
621
|
+
if (remoteWatchRoute !== undefined) {
|
|
622
|
+
let parent = getParentPathStr(path);
|
|
623
|
+
let ranges = this.parentsSynced.get(parent);
|
|
624
|
+
// NOTE: We can't cache this because when we stop watching the parent, the remote watch will remove it, which will cause us to be no longer synced. However, it's not going to know to go into our synced cache and remove it.
|
|
625
|
+
if (ranges === true) return true;
|
|
626
|
+
if (ranges) {
|
|
627
|
+
// NOTE: This code is unfortunately fragile. It depends on the ranges that we're watching on the remote to match up with the synced ranges that we're keeping track of.
|
|
628
|
+
// HOWEVER I think it works fine because if we disconnect from a remote and so we stop watching them, the remote watcher will remove it, and so it will no longer match the route. And then it'll try to find something that matches that previous route exactly. Perhaps redoing all of the remote watchers if to in order to find something that hashes consistently. So I think it should be safe.
|
|
629
|
+
for (let range of ranges) {
|
|
630
|
+
if (range.start <= remoteWatchRoute && remoteWatchRoute < range.end) {
|
|
631
|
+
return true;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
607
635
|
}
|
|
608
636
|
|
|
609
637
|
return false;
|
|
610
638
|
}
|
|
611
|
-
public isParentSynced(
|
|
612
|
-
let originalPath =
|
|
613
|
-
let range = decodeParentFilter(
|
|
614
|
-
|
|
615
|
-
if (PathRouter.isLocalPath(
|
|
639
|
+
public isParentSynced(parentPath: string) {
|
|
640
|
+
let originalPath = parentPath;
|
|
641
|
+
let range = decodeParentFilter(parentPath);
|
|
642
|
+
parentPath = hack_stripPackedPath(parentPath);
|
|
643
|
+
if (PathRouter.isLocalPath(parentPath)) return true;
|
|
616
644
|
|
|
617
|
-
let synced = this.parentsSynced.get(originalPath) || this.parentsSynced.get(
|
|
645
|
+
let synced = this.parentsSynced.get(originalPath) || this.parentsSynced.get(parentPath);
|
|
618
646
|
if (synced === true) return true;
|
|
619
647
|
// See if the ranges received so far cover the requested range. If we only request a partial range, then this will always be the case. We'll never fully synchronize the path.
|
|
620
648
|
if (synced && range) {
|
|
@@ -1109,4 +1137,5 @@ setImmediate(() => {
|
|
|
1109
1137
|
export { pathValueArchives };
|
|
1110
1138
|
|
|
1111
1139
|
(globalThis as any).core = module.exports;
|
|
1140
|
+
(globalThis as any).authorityStorage = authorityStorage;
|
|
1112
1141
|
(globalThis as any).SocketFunction = SocketFunction;
|
|
@@ -143,6 +143,19 @@ export class RemoteWatcher {
|
|
|
143
143
|
return x.start <= route && route < x.end;
|
|
144
144
|
});
|
|
145
145
|
}
|
|
146
|
+
public getRemoteParentWatchRoute(path: string) {
|
|
147
|
+
let parentPath = getParentPathStr(path);
|
|
148
|
+
let watchObj = this.remoteWatchParents2.get(parentPath);
|
|
149
|
+
if (!watchObj) return undefined;
|
|
150
|
+
// NOTE: PathRouter.getChildReadNodes PROMISES That all of the nodes will hash in the same way, and if they all hash in the same way, and they have different ranges, then every path will uniquely map to a single value.
|
|
151
|
+
for (let range of watchObj.ranges) {
|
|
152
|
+
let route = PathRouter.getRouteFull({ path, spec: range.authoritySpec });
|
|
153
|
+
if (range.start <= route && route < range.end) {
|
|
154
|
+
return route;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
146
159
|
|
|
147
160
|
|
|
148
161
|
public debugIsWatchingPath(path: string) {
|
|
@@ -486,7 +499,6 @@ export class RemoteWatcher {
|
|
|
486
499
|
auditLog("Asking to watch parent path", { path, authorityId, targetNodeThreadId: debugNodeThread(authorityId) });
|
|
487
500
|
}
|
|
488
501
|
}
|
|
489
|
-
|
|
490
502
|
logErrors(RemoteWatcher.REMOTE_WATCH_FUNCTION(config, authorityId));
|
|
491
503
|
}
|
|
492
504
|
});
|
|
@@ -20,7 +20,7 @@ import { logErrors } from "../errors";
|
|
|
20
20
|
import { getParentPathStr, getPathFromStr, hack_stripPackedPath } from "../path";
|
|
21
21
|
import { measureBlock, measureFnc } from "socket-function/src/profiling/measure";
|
|
22
22
|
import { pathValueCommitter } from "../0-path-value-core/PathValueController";
|
|
23
|
-
import { PathValue, Value, getNextTime, Time, ReadLock, MAX_CHANGE_AGE, authorityStorage, getCreatorId, WatchConfig } from "../0-path-value-core/pathValueCore";
|
|
23
|
+
import { PathValue, Value, getNextTime, Time, ReadLock, MAX_CHANGE_AGE, authorityStorage, getCreatorId, WatchConfig, hashPathForTransaction } from "../0-path-value-core/pathValueCore";
|
|
24
24
|
import { pathWatcher } from "../0-path-value-core/PathWatcher";
|
|
25
25
|
import { blue, green, red } from "socket-function/src/formatting/logColors";
|
|
26
26
|
import { runInfinitePoll } from "socket-function/src/batching";
|
|
@@ -681,6 +681,8 @@ export class ClientWatcher {
|
|
|
681
681
|
/** Causes us to NOT ACTUALLY write values, but just return what we would write, if !dryRun */
|
|
682
682
|
dryRun?: boolean;
|
|
683
683
|
source?: string;
|
|
684
|
+
// If we read values in the past, not taking the latest value, we need to be told about this because this changes how we will be creating the transaction.
|
|
685
|
+
historyBasedReads?: boolean;
|
|
684
686
|
}
|
|
685
687
|
): PathValue[] {
|
|
686
688
|
const { values, locks, eventPaths } = config;
|
|
@@ -704,6 +706,13 @@ export class ClientWatcher {
|
|
|
704
706
|
source = debugName;
|
|
705
707
|
}
|
|
706
708
|
|
|
709
|
+
// Create transaction paths array - hash all paths and reuse the same array for all PathValues
|
|
710
|
+
// - Sorting doesn't matter. I guess it just makes it so it's technically going to encode more efficiently... or something?
|
|
711
|
+
let transactionPaths: string[] | undefined = Array.from(values.keys()).map(hashPathForTransaction).sort();
|
|
712
|
+
if (config.historyBasedReads) {
|
|
713
|
+
transactionPaths = undefined;
|
|
714
|
+
}
|
|
715
|
+
|
|
707
716
|
let pathValues: PathValue[] = [];
|
|
708
717
|
for (let [path, value] of values) {
|
|
709
718
|
let valueIsEvent = event;
|
|
@@ -711,6 +720,8 @@ export class ClientWatcher {
|
|
|
711
720
|
valueIsEvent = true;
|
|
712
721
|
}
|
|
713
722
|
let isTransparent = value === undefined || config.forcedUndefinedWrites?.has(path);
|
|
723
|
+
// IMPORTANT! With the exception of epoch values, this is really the only place that actually creates path values. All path values will be created on this specific line.
|
|
724
|
+
// - In the future, we could create PathValues in different places. However, that would require PathValue generation that doesn't use ClientWatcher, which would be complex, and probably not worth it. Manually creating path values. While technically faster, the only cases in which you would need that speed would then overload the path value server and all the watchers, and so they're not really viable. Not to mention the fact that using ClientWatcher and Proxy PathWatcher are very fast anyway and can easily create probably hundreds of thousands of writes a second.
|
|
714
725
|
let pathValue: PathValue = pathValueCtor({
|
|
715
726
|
path,
|
|
716
727
|
valid: true,
|
|
@@ -722,6 +733,7 @@ export class ClientWatcher {
|
|
|
722
733
|
event: valueIsEvent,
|
|
723
734
|
isTransparent,
|
|
724
735
|
source,
|
|
736
|
+
transactionPaths,
|
|
725
737
|
});
|
|
726
738
|
pathValues.push(pathValue);
|
|
727
739
|
}
|
|
@@ -234,6 +234,17 @@ export interface WatcherOptions<Result> {
|
|
|
234
234
|
/** AKA, isAllSynced is always true. Useful for cases when we want to show partially loaded values. */
|
|
235
235
|
commitAllRuns?: boolean;
|
|
236
236
|
|
|
237
|
+
|
|
238
|
+
/** Path values contain some information about all the paths that were written at once, which we call a transaction. We can use this to detect when we are missing part of a transaction and wait until we receive that part of the transaction.
|
|
239
|
+
- If we don't wait, our writes will get rejected, and so the system will still be valid, however, there are a few reasons we want to avoid this:
|
|
240
|
+
- If we are trying to trigger external data, such as API calls, we want to reduce raise conditions. The values still might be rejected entirely, however, at least there won't be a partial read, whereas partial reads are significantly more common than rejections.
|
|
241
|
+
- Rejections can cause view stuttering, where the view goes to a bad state before it goes to a good state. If we just wait for the transaction to complete before trying to commit a value, such as in function runner, we can remove this stutter.
|
|
242
|
+
- Waiting can be more efficient than committing it and having to reject it, especially if something else depends on our value and then it has to be rejected and rerun.
|
|
243
|
+
- Not all writers will automatically rerun on rejections. They should be able to handle rejections. However, it might cause downtime and it might have to put the system into a partial recovery state. It's better to avoid this and just wait for the full transaction.
|
|
244
|
+
- Because this is so useful and so cheap to calculate, this flag will be set by default by most commit-style templates. It should be safe to set in watch style templates, However, it's not on by default there as, technically speaking, waiting for incomplete transactions will cause higher latency to render, And by default we prefer to render fast and usually get it right rather than render slow and always render the right thing.
|
|
245
|
+
*/
|
|
246
|
+
waitForIncompleteTransactions?: boolean;
|
|
247
|
+
|
|
237
248
|
// NOTE: The reason there isn't throttle support here is very frequently when you want to throttle one component rendering, it's because you have many components. So you actually want to throttle many components and have them throttle in conjunction with each other, which results in the logic becoming complicated.
|
|
238
249
|
// - But maybe we should support the single throttle case anyways?
|
|
239
250
|
}
|
|
@@ -1176,6 +1187,9 @@ export class PathValueProxyWatcher {
|
|
|
1176
1187
|
}
|
|
1177
1188
|
};
|
|
1178
1189
|
watcher.hasAnyUnsyncedAccesses = () => {
|
|
1190
|
+
if (watcher.options.waitForIncompleteTransactions) {
|
|
1191
|
+
waitIfReceivedIncompleteTransaction();
|
|
1192
|
+
}
|
|
1179
1193
|
return (
|
|
1180
1194
|
watcher.pendingUnsyncedAccesses.size > 0
|
|
1181
1195
|
|| watcher.pendingUnsyncedParentAccesses.size > 0
|
|
@@ -1474,6 +1488,8 @@ export class PathValueProxyWatcher {
|
|
|
1474
1488
|
}
|
|
1475
1489
|
watcher.countSinceLastFullSync++;
|
|
1476
1490
|
if (watcher.countSinceLastFullSync > 10) {
|
|
1491
|
+
require("debugbreak")(2);
|
|
1492
|
+
debugger;
|
|
1477
1493
|
console.warn(`Watcher ${watcher.debugName} has been unsynced for ${watcher.countSinceLastFullSync} times. This is fine, but maybe optimize it. Why is it cascading?`, watcher.lastUnsyncedAccesses, watcher.lastUnsyncedParentAccesses, watcher.options.watchFunction);
|
|
1478
1494
|
}
|
|
1479
1495
|
if (watcher.countSinceLastFullSync > 500) {
|
|
@@ -1552,6 +1568,15 @@ export class PathValueProxyWatcher {
|
|
|
1552
1568
|
|
|
1553
1569
|
if (watcher.pendingWrites.size > 0) {
|
|
1554
1570
|
let locks: ReadLock[] = [];
|
|
1571
|
+
let historyBasedReads = false;
|
|
1572
|
+
for (let time of watcher.pendingAccesses.keys()) {
|
|
1573
|
+
if (time === undefined) continue;
|
|
1574
|
+
// If we're reading from a specific time, and it isn't just our write time, then we're reading in the past.
|
|
1575
|
+
if (compareTime(time, writeTime) !== 0) {
|
|
1576
|
+
historyBasedReads = true;
|
|
1577
|
+
break;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1555
1580
|
|
|
1556
1581
|
if (!watcher.options.noLocks && !watcher.options.unsafeNoLocks) {
|
|
1557
1582
|
for (let [readTime, values] of watcher.pendingAccesses) {
|
|
@@ -1651,6 +1676,7 @@ export class PathValueProxyWatcher {
|
|
|
1651
1676
|
dryRun: options.dryRun,
|
|
1652
1677
|
forcedUndefinedWrites,
|
|
1653
1678
|
source: options.source,
|
|
1679
|
+
historyBasedReads,
|
|
1654
1680
|
});
|
|
1655
1681
|
}
|
|
1656
1682
|
|
|
@@ -1966,6 +1992,7 @@ export class PathValueProxyWatcher {
|
|
|
1966
1992
|
): Promise<Result> {
|
|
1967
1993
|
options = {
|
|
1968
1994
|
canWrite: true,
|
|
1995
|
+
waitForIncompleteTransactions: true,
|
|
1969
1996
|
...options,
|
|
1970
1997
|
};
|
|
1971
1998
|
|
|
@@ -2507,4 +2534,5 @@ registerPeriodic(function logWatcherTypes() {
|
|
|
2507
2534
|
// #endregion Proxy Ordering
|
|
2508
2535
|
|
|
2509
2536
|
import { inlineNestedCalls } from "../3-path-functions/syncSchema"; import { LOCAL_DOMAIN_PATH } from "../0-path-value-core/PathRouter";
|
|
2537
|
+
import { waitIfReceivedIncompleteTransaction } from "./TransactionDelayer";
|
|
2510
2538
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { delay } from "socket-function/src/batching";
|
|
2
|
+
import { PathValue, hashPathForTransaction, compareTime, Time } from "../0-path-value-core/pathValueCore";
|
|
3
|
+
import { proxyWatcher } from "./PathValueProxyWatcher";
|
|
4
|
+
|
|
5
|
+
const MISSING_TRANSACTION_PART_TIMEOUT = 5000;
|
|
6
|
+
|
|
7
|
+
export function isMissingTransactionPart(pathValues: PathValue[]): { time: number; path: string; } | undefined {
|
|
8
|
+
// Create a map of path hash -> time for quick lookups
|
|
9
|
+
const pathHashToTime = new Map<string, {
|
|
10
|
+
time: Time;
|
|
11
|
+
path: string;
|
|
12
|
+
}>();
|
|
13
|
+
for (const pv of pathValues) {
|
|
14
|
+
const hash = hashPathForTransaction(pv.path);
|
|
15
|
+
const existing = pathHashToTime.get(hash);
|
|
16
|
+
// Keep the latest time for this path
|
|
17
|
+
if (!existing || compareTime(pv.time, existing.time) > 0) {
|
|
18
|
+
pathHashToTime.set(hash, {
|
|
19
|
+
time: pv.time,
|
|
20
|
+
path: pv.path,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Group PathValues by their transactionPaths array reference
|
|
26
|
+
const uniqueTransactions = new Map<string[], Time>();
|
|
27
|
+
for (const pathValue of pathValues) {
|
|
28
|
+
if (!pathValue.transactionPaths || pathValue.transactionPaths.length === 0) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const existing = uniqueTransactions.get(pathValue.transactionPaths);
|
|
33
|
+
if (!existing) {
|
|
34
|
+
uniqueTransactions.set(pathValue.transactionPaths, pathValue.time);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let newestWaitTime: {
|
|
39
|
+
time: number;
|
|
40
|
+
path: string;
|
|
41
|
+
} | undefined = undefined;
|
|
42
|
+
|
|
43
|
+
// Check each unique transaction only once
|
|
44
|
+
for (const [transactionPaths, transactionTime] of uniqueTransactions) {
|
|
45
|
+
for (const transactionPathHash of transactionPaths) {
|
|
46
|
+
const pathTime = pathHashToTime.get(transactionPathHash);
|
|
47
|
+
if (!pathTime) continue;
|
|
48
|
+
|
|
49
|
+
// We know that there's a value with this path hash at the transaction time. And so if the value that we've read is before that, then we haven't read the latest value. So we know that we're missing part of the transaction.
|
|
50
|
+
const timeComparison = compareTime(pathTime.time, transactionTime);
|
|
51
|
+
if (timeComparison < 0) {
|
|
52
|
+
if (!newestWaitTime || transactionTime.time > newestWaitTime.time) {
|
|
53
|
+
newestWaitTime = {
|
|
54
|
+
time: transactionTime.time,
|
|
55
|
+
path: pathTime.path,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!newestWaitTime) return undefined;
|
|
62
|
+
return newestWaitTime;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getAllPendingPathValueReads() {
|
|
66
|
+
let values: PathValue[] = [];
|
|
67
|
+
let watcher = proxyWatcher.getTriggeredWatcher();
|
|
68
|
+
for (let map of watcher.pendingAccesses.values()) {
|
|
69
|
+
for (let pathValue of map.values()) {
|
|
70
|
+
values.push(pathValue.pathValue);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return values;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
export function waitIfReceivedIncompleteTransaction() {
|
|
78
|
+
// 1) Keep track of the last proxy watcher we just registered it for. And then if the proxy watcher is the same as that one and that proxy watcher is waiting on a promise, don't register it again.
|
|
79
|
+
let watcher = proxyWatcher.getTriggeredWatcher();
|
|
80
|
+
// We don't want to register a whole bunch of duplicate promises. So if somebody's waiting, there's no need to even do our check.
|
|
81
|
+
if (watcher.specialPromiseUnsynced) return;
|
|
82
|
+
|
|
83
|
+
let newestTime = isMissingTransactionPart(getAllPendingPathValueReads());
|
|
84
|
+
if (!newestTime) return;
|
|
85
|
+
|
|
86
|
+
let timeout = newestTime.time + MISSING_TRANSACTION_PART_TIMEOUT;
|
|
87
|
+
let now = Date.now();
|
|
88
|
+
if (timeout < now) return;
|
|
89
|
+
|
|
90
|
+
let promise = delay(timeout - now);
|
|
91
|
+
// This really nicely both blocks it from finishing because of the waiting for the promise and also triggers it automatically when the delay finishes. HOWEVER, In practice, the promise should never be required to trigger it. It should trigger when we receive the missing parts of the transaction, which we will be, of course, watching as they're in the path values that we're accessing. The timeout is just in case something goes wrong and we incorrectly think we should receive the values, but we won't. That way, eventually, we do commit the value.
|
|
92
|
+
// ALSO! Plus the hash I think only stores like 48 bits. So the chance of collision is somewhat high, especially if we're accessing thousands of paths. So sometimes this will trigger even though we're not missing any part just because we had a collision between the path hashes.
|
|
93
|
+
proxyWatcher.triggerOnPromiseFinish(promise, { waitReason: "Missing transaction part" });
|
|
94
|
+
console.error(`(NOT an error, convert this to a warning after we finish testing). Waiting for missing transaction part ${newestTime.path} which was written at time ${newestTime.time} (now is ${now}). We have parts of this transaction, but we are missing this specific path.`);
|
|
95
|
+
}
|
|
@@ -131,6 +131,7 @@ function predictCallBase(config: {
|
|
|
131
131
|
let result = pathValueSerializer.getPathValue(resultObj) as FunctionResult | undefined;
|
|
132
132
|
if (!result) return;
|
|
133
133
|
if (result.lastInternalLoopCount === -1) return;
|
|
134
|
+
cleanupPrediction();
|
|
134
135
|
actualValueFinished.resolve();
|
|
135
136
|
for (let callback of onPredictionFinishedCallbacks) {
|
|
136
137
|
callback({ callId: call.CallId, result, functionId: call.FunctionId });
|
|
@@ -306,7 +307,6 @@ function predictCallBase(config: {
|
|
|
306
307
|
|
|
307
308
|
let didCancel = false;
|
|
308
309
|
function rejectPrediction() {
|
|
309
|
-
predictions;
|
|
310
310
|
if (didCancel) return;
|
|
311
311
|
didCancel = true;
|
|
312
312
|
if (!predictions) return;
|
package/src/config.ts
CHANGED
|
@@ -30,6 +30,7 @@ let yargObj = parseArgsFactory()
|
|
|
30
30
|
desc: "Track all audit logs to disk. This might end up writing A LOT of data."
|
|
31
31
|
})
|
|
32
32
|
.option("logbackblaze", { type: "boolean", desc: "Log all backblaze activity to disk." })
|
|
33
|
+
.option("slowdown", { type: "number", desc: "Delay all input data values by this amount of time, pretending like we didn't even receive it until this time is up." })
|
|
33
34
|
.argv
|
|
34
35
|
;
|
|
35
36
|
type QuerysubConfig = {
|
|
@@ -51,6 +52,10 @@ let querysubConfig = lazy((): QuerysubConfig => {
|
|
|
51
52
|
}
|
|
52
53
|
});
|
|
53
54
|
|
|
55
|
+
export function getSlowdown() {
|
|
56
|
+
return yargObj.slowdown;
|
|
57
|
+
}
|
|
58
|
+
|
|
54
59
|
export function isLogBackblaze() {
|
|
55
60
|
return !!yargObj.logbackblaze;
|
|
56
61
|
}
|
|
@@ -12,15 +12,19 @@ import { authorityStorage } from "../0-path-value-core/pathValueCore";
|
|
|
12
12
|
import { getAllNodeIds } from "../-f-node-discovery/NodeDiscovery";
|
|
13
13
|
import { MachineThreadInfoController } from "./MachineThreadInfo";
|
|
14
14
|
import { pathValueSerializer } from "../-h-path-value-serialize/PathValueSerializer";
|
|
15
|
-
import { sort } from "socket-function/src/misc";
|
|
15
|
+
import { list, sort } from "socket-function/src/misc";
|
|
16
16
|
import { PathAuditerController } from "./pathAuditer";
|
|
17
17
|
import { t } from "../2-proxy/schema2";
|
|
18
|
+
import { proxyWatcher } from "../2-proxy/PathValueProxyWatcher";
|
|
19
|
+
import { remoteWatcher } from "../1-path-client/RemoteWatcher";
|
|
20
|
+
import { getAllPendingPathValueReads, isMissingTransactionPart, waitIfReceivedIncompleteTransaction } from "../2-proxy/TransactionDelayer";
|
|
21
|
+
|
|
22
|
+
module.hotreload = true;
|
|
18
23
|
|
|
19
|
-
//todonext
|
|
20
|
-
// So... We need to double write, to write to multiple keys at once, in something that's not a lookup, or no, is a lookup, but before we deploy, so it doesn't have the proper or no, a nested lookup, even better.
|
|
21
24
|
|
|
22
25
|
let { data, functions } = Querysub.createSchema({
|
|
23
|
-
values: t.lookup(t.number)
|
|
26
|
+
values: t.lookup(t.number),
|
|
27
|
+
cardinality: t.number,
|
|
24
28
|
})({
|
|
25
29
|
functions: {
|
|
26
30
|
setValue(key: string, value: number) {
|
|
@@ -29,6 +33,37 @@ let { data, functions } = Querysub.createSchema({
|
|
|
29
33
|
incrementValue(key: string) {
|
|
30
34
|
data().values[key]++;
|
|
31
35
|
},
|
|
36
|
+
deleteValue(key: string) {
|
|
37
|
+
delete data().values[key];
|
|
38
|
+
},
|
|
39
|
+
syncValues(keys: string[], removeKeys: string[]) {
|
|
40
|
+
Querysub.noLocks(() => {
|
|
41
|
+
for (let key of removeKeys) {
|
|
42
|
+
delete data().values[key];
|
|
43
|
+
}
|
|
44
|
+
let max = Math.max(...keys.map(x => data().values[x])) + 1;
|
|
45
|
+
for (let key of keys) {
|
|
46
|
+
data().values[key] = max;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
checkCardinality(keys: string[]) {
|
|
51
|
+
Querysub.noLocks(() => {
|
|
52
|
+
let cardinality = new Set(keys.map(x => data().values[x]));
|
|
53
|
+
let max = Math.max(...keys.map(x => data().values[x]));
|
|
54
|
+
data().cardinality = cardinality.size + max / 1000;
|
|
55
|
+
let watcher = proxyWatcher.getTriggeredWatcher();
|
|
56
|
+
if (watcher.pendingUnsyncedAccesses.size > 0) {
|
|
57
|
+
console.log("Unsynced accesses", [...watcher.pendingUnsyncedAccesses]);
|
|
58
|
+
}
|
|
59
|
+
for (let path of watcher.pendingWatches.paths) {
|
|
60
|
+
if (!path.includes("syncText")) continue;
|
|
61
|
+
let pathValue = authorityStorage.getValueAtTime(path, undefined, false, "noAudit");
|
|
62
|
+
let source = remoteWatcher.getExistingWatchRemoteNodeId(path);
|
|
63
|
+
console.log("Path value", pathValue?.time.time, pathValueSerializer.getPathValue(pathValue), path, source);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
},
|
|
32
67
|
},
|
|
33
68
|
module,
|
|
34
69
|
moduleId: "syncText",
|
|
@@ -45,6 +80,26 @@ let { data, functions } = Querysub.createSchema({
|
|
|
45
80
|
getKey: (key: string) => key,
|
|
46
81
|
},
|
|
47
82
|
},
|
|
83
|
+
deleteValue: {
|
|
84
|
+
keyOverride: {
|
|
85
|
+
getPrefix: data => data().values,
|
|
86
|
+
getKey: (key: string) => key,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
syncValues: {
|
|
90
|
+
nopredict: true,
|
|
91
|
+
keyOverride: {
|
|
92
|
+
getPrefix: data => data().values,
|
|
93
|
+
getKey: () => "f1.2",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
checkCardinality: {
|
|
97
|
+
nopredict: true,
|
|
98
|
+
keyOverride: {
|
|
99
|
+
getPrefix: data => data().values,
|
|
100
|
+
getKey: () => "f2",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
48
103
|
},
|
|
49
104
|
permissions: {
|
|
50
105
|
PERMISSIONS: isSuperUserPERMISSIONS
|
|
@@ -98,6 +153,8 @@ export class SyncTestPage extends qreact.Component {
|
|
|
98
153
|
});
|
|
99
154
|
}
|
|
100
155
|
|
|
156
|
+
let keys = Object.keys(values);
|
|
157
|
+
|
|
101
158
|
return (
|
|
102
159
|
<div className={css.pad2(20).vbox(40)}>
|
|
103
160
|
<h1>Sync Test Page</h1>
|
|
@@ -138,6 +195,7 @@ export class SyncTestPage extends qreact.Component {
|
|
|
138
195
|
functions.setValue(key, +value);
|
|
139
196
|
refreshDelayed();
|
|
140
197
|
}} />
|
|
198
|
+
<Button onClick={() => { functions.deleteValue(key); refreshDelayed(); }}>Delete</Button>
|
|
141
199
|
</div>
|
|
142
200
|
<div className={css.hbox(10, 2).wrap.marginLeft(20)}>
|
|
143
201
|
{allThreads.map(thread => {
|
|
@@ -184,11 +242,49 @@ export class SyncTestPage extends qreact.Component {
|
|
|
184
242
|
refreshDelayed();
|
|
185
243
|
}}
|
|
186
244
|
/>
|
|
187
|
-
<
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
245
|
+
<div>
|
|
246
|
+
Cardinality: {data().cardinality}
|
|
247
|
+
</div>
|
|
248
|
+
<div className={css.hbox(10)}>
|
|
249
|
+
<Button onClick={() => {
|
|
250
|
+
syncTestController.refreshAll();
|
|
251
|
+
}}>
|
|
252
|
+
Refresh
|
|
253
|
+
</Button>
|
|
254
|
+
|
|
255
|
+
<Button onClick={() => {
|
|
256
|
+
let randKeys = list(4).map(x => Math.random() + "");
|
|
257
|
+
functions.syncValues(randKeys, keys);
|
|
258
|
+
refreshDelayed();
|
|
259
|
+
}}>
|
|
260
|
+
Set All Values
|
|
261
|
+
</Button>
|
|
262
|
+
<Button onClick={() => {
|
|
263
|
+
functions.syncValues(keys, []);
|
|
264
|
+
refreshDelayed();
|
|
265
|
+
}}>
|
|
266
|
+
Inc All Values
|
|
267
|
+
</Button>
|
|
268
|
+
<Button onClick={() => {
|
|
269
|
+
functions.checkCardinality(keys);
|
|
270
|
+
}}>
|
|
271
|
+
Check Cardinality
|
|
272
|
+
</Button>
|
|
273
|
+
<Button onClick={() => {
|
|
274
|
+
functions.syncValues(keys, []);
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
Querysub.commit(() => {
|
|
277
|
+
functions.checkCardinality(keys);
|
|
278
|
+
});
|
|
279
|
+
}, 500);
|
|
280
|
+
refreshDelayed();
|
|
281
|
+
}}>
|
|
282
|
+
Both
|
|
283
|
+
</Button>
|
|
284
|
+
</div>
|
|
285
|
+
<div>
|
|
286
|
+
{isMissingTransactionPart(getAllPendingPathValueReads()) && "Missing transaction part" || "No missing transaction part"}
|
|
287
|
+
</div>
|
|
192
288
|
</div>
|
|
193
289
|
);
|
|
194
290
|
}
|