@indigoai-us/hq-cloud 6.15.75 → 6.15.76
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/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +12 -2
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +4 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +63 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +5 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +3 -0
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +45 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/watcher.d.ts +59 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +186 -15
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +311 -3
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
package/dist/watcher.js
CHANGED
|
@@ -30,8 +30,18 @@ const DEBOUNCE_MS = 2000;
|
|
|
30
30
|
*/
|
|
31
31
|
export const DEFAULT_EVENT_DEBOUNCE_MS = 15_000;
|
|
32
32
|
export const DEFAULT_EVENT_MAX_WAIT_MS = 120_000;
|
|
33
|
+
/**
|
|
34
|
+
* A small, quiet save should not pay the full burst-coalescing window. This
|
|
35
|
+
* is deliberately still long enough to absorb editor atomic-save chatter.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS = 2_500;
|
|
38
|
+
export const DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS = 8;
|
|
39
|
+
export const DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES = 1024 * 1024;
|
|
33
40
|
export const EVENT_DEBOUNCE_MS_ENV = "HQ_SYNC_EVENT_DEBOUNCE_MS";
|
|
34
41
|
export const EVENT_MAX_WAIT_MS_ENV = "HQ_SYNC_EVENT_MAX_WAIT_MS";
|
|
42
|
+
export const EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_DEBOUNCE_MS";
|
|
43
|
+
export const EVENT_SMALL_BATCH_MAX_PATHS_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_MAX_PATHS";
|
|
44
|
+
export const EVENT_SMALL_BATCH_MAX_BYTES_ENV = "HQ_SYNC_EVENT_SMALL_BATCH_MAX_BYTES";
|
|
35
45
|
/**
|
|
36
46
|
* Resolve the event-pass debounce configuration from the environment.
|
|
37
47
|
* `HQ_SYNC_EVENT_DEBOUNCE_MS` / `HQ_SYNC_EVENT_MAX_WAIT_MS`; 0, negative,
|
|
@@ -51,7 +61,15 @@ export function resolveEventDebounceConfig(env = process.env) {
|
|
|
51
61
|
};
|
|
52
62
|
const debounceMs = read(EVENT_DEBOUNCE_MS_ENV, DEFAULT_EVENT_DEBOUNCE_MS);
|
|
53
63
|
const maxWaitMs = Math.max(debounceMs, read(EVENT_MAX_WAIT_MS_ENV, DEFAULT_EVENT_MAX_WAIT_MS));
|
|
54
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
debounceMs,
|
|
66
|
+
maxWaitMs,
|
|
67
|
+
// Never let a tuning error turn the small-batch path into a longer delay
|
|
68
|
+
// than the ordinary debounce window.
|
|
69
|
+
smallBatchDebounceMs: Math.min(debounceMs, read(EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS)),
|
|
70
|
+
smallBatchMaxPaths: read(EVENT_SMALL_BATCH_MAX_PATHS_ENV, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS),
|
|
71
|
+
smallBatchMaxBytes: read(EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES),
|
|
72
|
+
};
|
|
55
73
|
}
|
|
56
74
|
/**
|
|
57
75
|
* A Linux chokidar watcher derives its directory budget from the host's real
|
|
@@ -907,6 +925,9 @@ function estimatePendingEntryBytes(absolutePath, relativePath) {
|
|
|
907
925
|
export class TreeWatcher {
|
|
908
926
|
hqRoot;
|
|
909
927
|
debounceMs;
|
|
928
|
+
smallBatchDebounceMs;
|
|
929
|
+
smallBatchMaxPaths;
|
|
930
|
+
smallBatchMaxBytes;
|
|
910
931
|
maxWaitMs;
|
|
911
932
|
clock;
|
|
912
933
|
shouldEmit;
|
|
@@ -935,6 +956,8 @@ export class TreeWatcher {
|
|
|
935
956
|
/** Paths accumulated for the current (in-flight) debounce window. */
|
|
936
957
|
pending = new Map();
|
|
937
958
|
pendingChanges = new Map();
|
|
959
|
+
/** Latest known payload bytes per path; null makes the whole batch bulk. */
|
|
960
|
+
pendingPayloadSizes = new Map();
|
|
938
961
|
knownKinds = new Map();
|
|
939
962
|
pendingBytes = 0;
|
|
940
963
|
overflowed = false;
|
|
@@ -956,6 +979,12 @@ export class TreeWatcher {
|
|
|
956
979
|
constructor(opts) {
|
|
957
980
|
this.hqRoot = opts.hqRoot;
|
|
958
981
|
this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
|
|
982
|
+
this.smallBatchDebounceMs =
|
|
983
|
+
opts.smallBatchDebounceMs !== undefined && opts.smallBatchDebounceMs > 0
|
|
984
|
+
? Math.min(this.debounceMs, Math.floor(opts.smallBatchDebounceMs))
|
|
985
|
+
: null;
|
|
986
|
+
this.smallBatchMaxPaths = Math.max(1, Math.floor(opts.smallBatchMaxPaths ?? 1));
|
|
987
|
+
this.smallBatchMaxBytes = Math.max(0, Math.floor(opts.smallBatchMaxBytes ?? 0));
|
|
959
988
|
this.maxWaitMs =
|
|
960
989
|
opts.maxWaitMs !== undefined && opts.maxWaitMs > 0
|
|
961
990
|
? Math.max(opts.maxWaitMs, this.debounceMs)
|
|
@@ -1343,6 +1372,7 @@ export class TreeWatcher {
|
|
|
1343
1372
|
this.arm();
|
|
1344
1373
|
return;
|
|
1345
1374
|
}
|
|
1375
|
+
this.pendingPayloadSizes.set(abs, this.payloadSizeFor(abs, kind));
|
|
1346
1376
|
const deleteSnapshotEpoch = (this.deleteSnapshotEpochs.get(abs) ?? 0) + 1;
|
|
1347
1377
|
this.deleteSnapshotEpochs.set(abs, deleteSnapshotEpoch);
|
|
1348
1378
|
let deleteSnapshots = [];
|
|
@@ -1371,6 +1401,7 @@ export class TreeWatcher {
|
|
|
1371
1401
|
// A newer live revision supersedes any queued unlink capture for this
|
|
1372
1402
|
// path before that job is allowed to acquire and mutate the journal.
|
|
1373
1403
|
this.deleteSnapshotEpochs.set(abs, (this.deleteSnapshotEpochs.get(abs) ?? 0) + 1);
|
|
1404
|
+
this.pendingPayloadSizes.set(abs, this.payloadSizeFor(abs, kind));
|
|
1374
1405
|
this.pendingChanges.set(abs, { kind });
|
|
1375
1406
|
// Only directories are needed to disambiguate a native recursive
|
|
1376
1407
|
// rename-after-delete. Unknown paths already correctly default to an
|
|
@@ -1495,11 +1526,14 @@ export class TreeWatcher {
|
|
|
1495
1526
|
if (this.windowDeadlineAt === null && Number.isFinite(this.maxWaitMs)) {
|
|
1496
1527
|
this.windowDeadlineAt = now + this.maxWaitMs;
|
|
1497
1528
|
}
|
|
1529
|
+
const quietWindowMs = this.isSmallPendingBatch()
|
|
1530
|
+
? this.smallBatchDebounceMs
|
|
1531
|
+
: this.debounceMs;
|
|
1498
1532
|
// Quiet-period debounce, capped by the window's max-wait deadline so a
|
|
1499
1533
|
// continuous event stream cannot postpone the emit forever.
|
|
1500
1534
|
const delayMs = this.windowDeadlineAt === null
|
|
1501
|
-
?
|
|
1502
|
-
: Math.max(0, Math.min(
|
|
1535
|
+
? quietWindowMs
|
|
1536
|
+
: Math.max(0, Math.min(quietWindowMs, this.windowDeadlineAt - now));
|
|
1503
1537
|
this.timer = this.clock.setTimeout(() => {
|
|
1504
1538
|
this.timer = null;
|
|
1505
1539
|
this.emit();
|
|
@@ -1541,6 +1575,7 @@ export class TreeWatcher {
|
|
|
1541
1575
|
this.pendingStartedAt = null;
|
|
1542
1576
|
this.pending.clear();
|
|
1543
1577
|
this.pendingChanges.clear();
|
|
1578
|
+
this.pendingPayloadSizes.clear();
|
|
1544
1579
|
this.pendingBytes = 0;
|
|
1545
1580
|
this.overflowed = false;
|
|
1546
1581
|
this.overflowLogged = false;
|
|
@@ -1549,6 +1584,39 @@ export class TreeWatcher {
|
|
|
1549
1584
|
this.droppedRouteHints.clear();
|
|
1550
1585
|
this.droppedRoutesUnknown = false;
|
|
1551
1586
|
}
|
|
1587
|
+
payloadSizeFor(absolutePath, kind) {
|
|
1588
|
+
// Exact deletes transfer no content; directory operations can expand to an
|
|
1589
|
+
// arbitrary subtree and therefore never take the latency shortcut.
|
|
1590
|
+
if (kind === "unlink")
|
|
1591
|
+
return 0;
|
|
1592
|
+
if (kind === "unlinkDir" || kind === "addDir")
|
|
1593
|
+
return null;
|
|
1594
|
+
try {
|
|
1595
|
+
const stat = fs.lstatSync(absolutePath);
|
|
1596
|
+
return stat.isFile() ? stat.size : null;
|
|
1597
|
+
}
|
|
1598
|
+
catch {
|
|
1599
|
+
// An upsert whose bytes cannot be read is not proven small.
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
isSmallPendingBatch() {
|
|
1604
|
+
if (this.smallBatchDebounceMs === null ||
|
|
1605
|
+
this.overflowed ||
|
|
1606
|
+
this.pending.size === 0 ||
|
|
1607
|
+
this.pending.size > this.smallBatchMaxPaths) {
|
|
1608
|
+
return false;
|
|
1609
|
+
}
|
|
1610
|
+
let bytes = 0;
|
|
1611
|
+
for (const size of this.pendingPayloadSizes.values()) {
|
|
1612
|
+
if (size === null)
|
|
1613
|
+
return false;
|
|
1614
|
+
bytes += size;
|
|
1615
|
+
if (bytes > this.smallBatchMaxBytes)
|
|
1616
|
+
return false;
|
|
1617
|
+
}
|
|
1618
|
+
return true;
|
|
1619
|
+
}
|
|
1552
1620
|
/**
|
|
1553
1621
|
* Recover mutations that happened while chokidar was building its initial
|
|
1554
1622
|
* watch set. `ignoreInitial` intentionally suppresses those initial events,
|
|
@@ -1709,6 +1777,19 @@ async function computeContentHash(absolutePath) {
|
|
|
1709
1777
|
}
|
|
1710
1778
|
/** Maximum paths remembered by one emitter to suppress unchanged announcements. */
|
|
1711
1779
|
export const DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS = 50_000;
|
|
1780
|
+
/** Minimum delay between successful upsert announcements for one path. */
|
|
1781
|
+
export const DEFAULT_PUBLISH_MIN_INTERVAL_MS = 60_000;
|
|
1782
|
+
export const PUBLISH_MIN_INTERVAL_MS_ENV = "HQ_SYNC_PUBLISH_MIN_INTERVAL_MS";
|
|
1783
|
+
/** Resolve the optional per-path announcement interval without accepting invalid values. */
|
|
1784
|
+
export function resolvePublishMinIntervalMs(env = process.env) {
|
|
1785
|
+
const raw = env[PUBLISH_MIN_INTERVAL_MS_ENV];
|
|
1786
|
+
if (raw === undefined || raw.trim() === "")
|
|
1787
|
+
return DEFAULT_PUBLISH_MIN_INTERVAL_MS;
|
|
1788
|
+
const value = Number(raw);
|
|
1789
|
+
return Number.isFinite(value) && value >= 0
|
|
1790
|
+
? Math.floor(value)
|
|
1791
|
+
: DEFAULT_PUBLISH_MIN_INTERVAL_MS;
|
|
1792
|
+
}
|
|
1712
1793
|
const PUBLISHED_CONTENT_HASH_RECORD_SET = 0x5031;
|
|
1713
1794
|
const PUBLISHED_CONTENT_HASH_RECORD_DELETE = 0x5032;
|
|
1714
1795
|
/**
|
|
@@ -1797,6 +1878,7 @@ function reduceDurablePublishedContentHashState(state, record) {
|
|
|
1797
1878
|
class BoundedPublishedContentHashes {
|
|
1798
1879
|
maxPaths;
|
|
1799
1880
|
hashes = new Map();
|
|
1881
|
+
publishedAt = new Map();
|
|
1800
1882
|
constructor(maxPaths) {
|
|
1801
1883
|
this.maxPaths = Math.max(1, Math.floor(maxPaths ?? DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS));
|
|
1802
1884
|
}
|
|
@@ -1808,18 +1890,27 @@ class BoundedPublishedContentHashes {
|
|
|
1808
1890
|
this.hashes.set(relativePath, hash);
|
|
1809
1891
|
return hash;
|
|
1810
1892
|
}
|
|
1811
|
-
set(relativePath, contentHash) {
|
|
1893
|
+
set(relativePath, contentHash, publishedAt) {
|
|
1812
1894
|
this.hashes.delete(relativePath);
|
|
1813
1895
|
this.hashes.set(relativePath, contentHash);
|
|
1896
|
+
if (publishedAt !== undefined)
|
|
1897
|
+
this.publishedAt.set(relativePath, publishedAt);
|
|
1898
|
+
else
|
|
1899
|
+
this.publishedAt.delete(relativePath);
|
|
1814
1900
|
while (this.hashes.size > this.maxPaths) {
|
|
1815
1901
|
const oldest = this.hashes.keys().next().value;
|
|
1816
1902
|
if (oldest === undefined)
|
|
1817
1903
|
return;
|
|
1818
1904
|
this.hashes.delete(oldest);
|
|
1905
|
+
this.publishedAt.delete(oldest);
|
|
1819
1906
|
}
|
|
1820
1907
|
}
|
|
1908
|
+
getPublishedAt(relativePath) {
|
|
1909
|
+
return this.publishedAt.get(relativePath);
|
|
1910
|
+
}
|
|
1821
1911
|
delete(relativePath) {
|
|
1822
1912
|
this.hashes.delete(relativePath);
|
|
1913
|
+
this.publishedAt.delete(relativePath);
|
|
1823
1914
|
}
|
|
1824
1915
|
}
|
|
1825
1916
|
/** Successful attempts retained in the batch outcome record for context. */
|
|
@@ -1879,6 +1970,7 @@ export class PushEventEmitter {
|
|
|
1879
1970
|
transport;
|
|
1880
1971
|
flagProvider;
|
|
1881
1972
|
now;
|
|
1973
|
+
clock;
|
|
1882
1974
|
onError;
|
|
1883
1975
|
onPublishOutcome;
|
|
1884
1976
|
logger;
|
|
@@ -1887,6 +1979,15 @@ export class PushEventEmitter {
|
|
|
1887
1979
|
lastPublishedContentHashes;
|
|
1888
1980
|
durablePublishedContentHashes;
|
|
1889
1981
|
lastPublishedContentHashMaxPaths;
|
|
1982
|
+
publishMinIntervalMs;
|
|
1983
|
+
/**
|
|
1984
|
+
* A captured event whose bytes were already uploaded by the scoped push that
|
|
1985
|
+
* called emitForBatch. Never retain only a path here: a later local rewrite
|
|
1986
|
+
* can otherwise make the trailing announcement describe bytes absent from S3.
|
|
1987
|
+
*/
|
|
1988
|
+
deferredPublishes = new Map();
|
|
1989
|
+
deferredPublishTimers = new Map();
|
|
1990
|
+
disposed = false;
|
|
1890
1991
|
internalSeq = 0;
|
|
1891
1992
|
nextSeq;
|
|
1892
1993
|
publishTail = Promise.resolve();
|
|
@@ -1896,6 +1997,7 @@ export class PushEventEmitter {
|
|
|
1896
1997
|
this.transport = opts.transport;
|
|
1897
1998
|
this.flagProvider = opts.flagProvider;
|
|
1898
1999
|
this.now = opts.now ?? (() => new Date());
|
|
2000
|
+
this.clock = opts.clock ?? systemClock;
|
|
1899
2001
|
this.logger = opts.logger;
|
|
1900
2002
|
this.telemetryClient = opts.telemetryClient;
|
|
1901
2003
|
this.telemetryClaims = opts.telemetryClaims;
|
|
@@ -1905,6 +2007,11 @@ export class PushEventEmitter {
|
|
|
1905
2007
|
this.lastPublishedContentHashMaxPaths = Math.max(1, Math.floor(opts.lastPublishedContentHashMaxPaths ??
|
|
1906
2008
|
DEFAULT_PUBLISHED_CONTENT_HASH_MAX_PATHS));
|
|
1907
2009
|
this.lastPublishedContentHashes = new BoundedPublishedContentHashes(this.lastPublishedContentHashMaxPaths);
|
|
2010
|
+
const configuredPublishMinIntervalMs = opts.publishMinIntervalMs ?? resolvePublishMinIntervalMs();
|
|
2011
|
+
this.publishMinIntervalMs =
|
|
2012
|
+
Number.isFinite(configuredPublishMinIntervalMs) && configuredPublishMinIntervalMs >= 0
|
|
2013
|
+
? Math.floor(configuredPublishMinIntervalMs)
|
|
2014
|
+
: DEFAULT_PUBLISH_MIN_INTERVAL_MS;
|
|
1908
2015
|
this.durablePublishedContentHashes = opts.lastPublishedContentHashStateDir
|
|
1909
2016
|
? this.openDurablePublishedContentHashes(opts.lastPublishedContentHashStateDir, opts.initialPublishedContentHashes ?? {})
|
|
1910
2017
|
: null;
|
|
@@ -1942,7 +2049,7 @@ export class PushEventEmitter {
|
|
|
1942
2049
|
* poll covers any miss).
|
|
1943
2050
|
*/
|
|
1944
2051
|
async emitForBatch(batch) {
|
|
1945
|
-
if (!this.enabled)
|
|
2052
|
+
if (this.disposed || !this.enabled)
|
|
1946
2053
|
return;
|
|
1947
2054
|
const entriesByRel = new Map();
|
|
1948
2055
|
for (const [absolutePath, relativePath] of batch.paths.entries()) {
|
|
@@ -1960,6 +2067,9 @@ export class PushEventEmitter {
|
|
|
1960
2067
|
const settled = await Promise.all(chunk.map(([absolutePath, relativePath]) => this.emitOne(absolutePath, relativePath)));
|
|
1961
2068
|
attempts.push(...settled.filter((attempt) => attempt !== undefined));
|
|
1962
2069
|
}
|
|
2070
|
+
this.reportPublishOutcome(attempts);
|
|
2071
|
+
}
|
|
2072
|
+
reportPublishOutcome(attempts) {
|
|
1963
2073
|
try {
|
|
1964
2074
|
this.onPublishOutcome(summarizePublishBatchOutcome(attempts));
|
|
1965
2075
|
}
|
|
@@ -1977,13 +2087,12 @@ export class PushEventEmitter {
|
|
|
1977
2087
|
return undefined;
|
|
1978
2088
|
const contentHash = await computeContentHash(absolutePath);
|
|
1979
2089
|
if (this.wasPublished(relativePath, contentHash)) {
|
|
2090
|
+
// This later, successfully-pushed revision restores the current
|
|
2091
|
+
// publication. Its stale deferred predecessor must not announce after
|
|
2092
|
+
// the scoped push has already replaced its S3 bytes.
|
|
2093
|
+
this.clearDeferredPublish(relativePath);
|
|
1980
2094
|
return undefined;
|
|
1981
2095
|
}
|
|
1982
|
-
// A different observed revision breaks the old publication guarantee even
|
|
1983
|
-
// if this transport attempt fails. Clearing first makes a later revert
|
|
1984
|
-
// re-announce instead of silently treating it as the old content.
|
|
1985
|
-
this.lastPublishedContentHashes.delete(relativePath);
|
|
1986
|
-
await this.deleteDurablePublishedContentHash(relativePath);
|
|
1987
2096
|
event = {
|
|
1988
2097
|
kind: "upsert",
|
|
1989
2098
|
relativePath,
|
|
@@ -1994,6 +2103,17 @@ export class PushEventEmitter {
|
|
|
1994
2103
|
sequenceNumber: this.nextSeq(),
|
|
1995
2104
|
eventTimestamp: this.now().toISOString(),
|
|
1996
2105
|
};
|
|
2106
|
+
const lastPublishedAt = this.lastPublishedContentHashes.getPublishedAt(relativePath);
|
|
2107
|
+
if (lastPublishedAt !== undefined &&
|
|
2108
|
+
this.clock.now() - lastPublishedAt < this.publishMinIntervalMs) {
|
|
2109
|
+
this.deferUpsert(event, lastPublishedAt);
|
|
2110
|
+
return undefined;
|
|
2111
|
+
}
|
|
2112
|
+
// A different observed revision breaks the old publication guarantee even
|
|
2113
|
+
// if this transport attempt fails. Clearing first makes a later revert
|
|
2114
|
+
// re-announce instead of silently treating it as the old content.
|
|
2115
|
+
this.lastPublishedContentHashes.delete(relativePath);
|
|
2116
|
+
await this.deleteDurablePublishedContentHash(relativePath);
|
|
1997
2117
|
}
|
|
1998
2118
|
catch (err) {
|
|
1999
2119
|
const code = err && typeof err === "object" && "code" in err
|
|
@@ -2011,6 +2131,7 @@ export class PushEventEmitter {
|
|
|
2011
2131
|
// targeted pull and let the vault-confirmed tombstone path remove it.
|
|
2012
2132
|
// Invalidate before the attempt for the same failure/recreation posture
|
|
2013
2133
|
// as a changed upsert above.
|
|
2134
|
+
this.clearDeferredPublish(relativePath);
|
|
2014
2135
|
this.lastPublishedContentHashes.delete(relativePath);
|
|
2015
2136
|
await this.deleteDurablePublishedContentHash(relativePath);
|
|
2016
2137
|
event = {
|
|
@@ -2022,6 +2143,9 @@ export class PushEventEmitter {
|
|
|
2022
2143
|
eventTimestamp: this.now().toISOString(),
|
|
2023
2144
|
};
|
|
2024
2145
|
}
|
|
2146
|
+
return this.publishEvent(event);
|
|
2147
|
+
}
|
|
2148
|
+
async publishEvent(event) {
|
|
2025
2149
|
// US-011: 1st link of the 3-log diagnostic chain. Stamps the same
|
|
2026
2150
|
// `sequenceNumber` the server `push.receive` log and the client
|
|
2027
2151
|
// `fanout.receive` log carry, so an operator can walk one event
|
|
@@ -2040,14 +2164,14 @@ export class PushEventEmitter {
|
|
|
2040
2164
|
try {
|
|
2041
2165
|
await this.transport.publish(event);
|
|
2042
2166
|
if (event.kind === "upsert" && event.contentHash !== undefined) {
|
|
2043
|
-
this.lastPublishedContentHashes.set(relativePath, event.contentHash);
|
|
2044
|
-
await this.setDurablePublishedContentHash(relativePath, event.contentHash);
|
|
2167
|
+
this.lastPublishedContentHashes.set(event.relativePath, event.contentHash, this.clock.now());
|
|
2168
|
+
await this.setDurablePublishedContentHash(event.relativePath, event.contentHash);
|
|
2045
2169
|
}
|
|
2046
2170
|
else {
|
|
2047
2171
|
// A later recreation with the same bytes is a real state transition,
|
|
2048
2172
|
// so a successfully published tombstone must never suppress it.
|
|
2049
|
-
this.lastPublishedContentHashes.delete(relativePath);
|
|
2050
|
-
await this.deleteDurablePublishedContentHash(relativePath);
|
|
2173
|
+
this.lastPublishedContentHashes.delete(event.relativePath);
|
|
2174
|
+
await this.deleteDurablePublishedContentHash(event.relativePath);
|
|
2051
2175
|
}
|
|
2052
2176
|
const outcome = getPublishSuccessOutcome(event);
|
|
2053
2177
|
return {
|
|
@@ -2059,7 +2183,7 @@ export class PushEventEmitter {
|
|
|
2059
2183
|
// Push failure (network / non-2xx / timeout). The cadence poll is the
|
|
2060
2184
|
// safety net — log + continue, never throw.
|
|
2061
2185
|
this.onError(err instanceof Error ? err : new Error(String(err)), {
|
|
2062
|
-
relativePath,
|
|
2186
|
+
relativePath: event.relativePath,
|
|
2063
2187
|
});
|
|
2064
2188
|
this.emitPublishFailureTelemetry(event.kind, "publish");
|
|
2065
2189
|
return {
|
|
@@ -2117,6 +2241,53 @@ export class PushEventEmitter {
|
|
|
2117
2241
|
});
|
|
2118
2242
|
}
|
|
2119
2243
|
}
|
|
2244
|
+
/** Remember one latest-wins uploaded revision until its publish interval ends. */
|
|
2245
|
+
deferUpsert(event, lastPublishedAt) {
|
|
2246
|
+
const relativePath = event.relativePath;
|
|
2247
|
+
this.deferredPublishes.delete(relativePath);
|
|
2248
|
+
this.deferredPublishes.set(relativePath, event);
|
|
2249
|
+
while (this.deferredPublishes.size > this.lastPublishedContentHashMaxPaths) {
|
|
2250
|
+
const oldest = this.deferredPublishes.keys().next().value;
|
|
2251
|
+
if (oldest === undefined)
|
|
2252
|
+
break;
|
|
2253
|
+
this.clearDeferredPublish(oldest);
|
|
2254
|
+
}
|
|
2255
|
+
if (this.deferredPublishTimers.has(relativePath))
|
|
2256
|
+
return;
|
|
2257
|
+
const delay = Math.max(0, lastPublishedAt + this.publishMinIntervalMs - this.clock.now());
|
|
2258
|
+
const timer = this.clock.setTimeout(() => this.flushDeferredPublish(relativePath), delay);
|
|
2259
|
+
this.deferredPublishTimers.set(relativePath, timer);
|
|
2260
|
+
}
|
|
2261
|
+
flushDeferredPublish(relativePath) {
|
|
2262
|
+
const deferred = this.deferredPublishes.get(relativePath);
|
|
2263
|
+
this.clearDeferredPublish(relativePath);
|
|
2264
|
+
if (this.disposed || !deferred)
|
|
2265
|
+
return;
|
|
2266
|
+
const run = this.publishTail.then(async () => {
|
|
2267
|
+
this.reportPublishOutcome([await this.publishEvent(deferred)]);
|
|
2268
|
+
}, async () => {
|
|
2269
|
+
this.reportPublishOutcome([await this.publishEvent(deferred)]);
|
|
2270
|
+
});
|
|
2271
|
+
this.publishTail = run.catch(() => undefined);
|
|
2272
|
+
}
|
|
2273
|
+
clearDeferredPublish(relativePath) {
|
|
2274
|
+
this.deferredPublishes.delete(relativePath);
|
|
2275
|
+
const timer = this.deferredPublishTimers.get(relativePath);
|
|
2276
|
+
if (timer !== undefined)
|
|
2277
|
+
this.clock.clearTimeout(timer);
|
|
2278
|
+
this.deferredPublishTimers.delete(relativePath);
|
|
2279
|
+
}
|
|
2280
|
+
/** Cancel every trailing publish and make future batch delivery inert. */
|
|
2281
|
+
dispose() {
|
|
2282
|
+
if (this.disposed)
|
|
2283
|
+
return;
|
|
2284
|
+
this.disposed = true;
|
|
2285
|
+
for (const timer of this.deferredPublishTimers.values()) {
|
|
2286
|
+
this.clock.clearTimeout(timer);
|
|
2287
|
+
}
|
|
2288
|
+
this.deferredPublishTimers.clear();
|
|
2289
|
+
this.deferredPublishes.clear();
|
|
2290
|
+
}
|
|
2120
2291
|
emitPublishFailureTelemetry(kind, stage) {
|
|
2121
2292
|
void emitCloudTelemetry(this.telemetryClient, {
|
|
2122
2293
|
eventName: "push_event_failed",
|