| … | … | @@ -346,9 +346,19 @@ export class DeployServer extends ReadyResource { |
| 346 | 346 | fs.mkdirSync(this._symlinkBlobsDir, { recursive: true }) |
| 347 | 347 | } |
| 348 | 348 | |
| 349 | // Mtime cache stats — measured at debug/observability level. When |
| 350 | // the fileIndex already has a matching (size, mtime) for a path, |
| 351 | // skip the readFileSync+hashChunk. For pgh-sidecar's content- |
| 352 | // addressed store the tree-hash dirs are immutable; this turns |
| 353 | // the legacy full-scan from O(disk bytes) into O(stat calls), |
| 354 | // typically ~100-1000× faster on warm restart. |
| 355 | let _cacheHits = 0 |
| 356 | let _cacheMisses = 0 |
| 357 | let _hashedBytes = 0 |
| 358 | |
| 349 | 359 | for (let i = 0; i < files.length; i++) { |
| 350 | 360 | const f = files[i] |
| 351 | | let diskPath, contentHash, mtime |
| 361 | let diskPath, contentHash, mtime, wasCached = false |
| 352 | 362 | if (f.isSymlink) { |
| 353 | 363 | const linkBytes = Buffer.from(f.linkTarget, 'utf8') |
| 354 | 364 | contentHash = hashChunk(linkBytes) |
| … | … | @@ -357,18 +367,41 @@ export class DeployServer extends ReadyResource { |
| 357 | 367 | mtime = Date.now() |
| 358 | 368 | } else { |
| 359 | 369 | diskPath = path.join(this.dir, f.path) |
| 360 | | contentHash = hashFile(diskPath) |
| 361 | | mtime = fs.statSync(diskPath).mtimeMs |
| 370 | const statMtime = fs.statSync(diskPath).mtimeMs |
| 371 | // mtime-based skip: if fileIndex has this path with matching size |
| 372 | // + mtime, the bytes haven't changed → reuse stored contentHash. |
| 373 | // Turns the legacy full-scan from O(disk bytes) to O(stat calls) |
| 374 | // when the index is warm. For pgh-sidecar's content-addressed |
| 375 | // store (tree-hash dirs are immutable) this is the common path |
| 376 | // on every restart. |
| 377 | const cached = await this._fileIndex.getFile(f.path).catch(() => null) |
| 378 | if (cached && cached.size === f.size && cached.mtime === statMtime && cached.contentHash) { |
| 379 | contentHash = cached.contentHash |
| 380 | mtime = statMtime |
| 381 | wasCached = true |
| 382 | _cacheHits++ |
| 383 | } else { |
| 384 | contentHash = hashFile(diskPath) |
| 385 | mtime = statMtime |
| 386 | _cacheMisses++ |
| 387 | _hashedBytes += f.size |
| 388 | } |
| 362 | 389 | } |
| 363 | 390 | f.diskPath = diskPath |
| 364 | 391 | |
| 365 | | await this._fileIndex.putFile(f.path, { |
| 366 | | contentHash, |
| 367 | | size: f.size, |
| 368 | | mtime |
| 369 | | }) |
| 392 | // Only write the fileIndex entry on cache miss / for symlinks — |
| 393 | // skips the autobase write tail entirely on a warm rescan. |
| 394 | if (!wasCached) { |
| 395 | await this._fileIndex.putFile(f.path, { |
| 396 | contentHash, |
| 397 | size: f.size, |
| 398 | mtime |
| 399 | }) |
| 400 | } |
| 370 | 401 | |
| 371 | | // For large files, prepare chunk map via TransferManager |
| 402 | // For large files, prepare chunk map via TransferManager. The |
| 403 | // chunk map is in-memory only, so cache hits still need this |
| 404 | // call (no disk re-read since the bytes haven't changed). |
| 372 | 405 | if (f.size >= CHUNK_THRESHOLD) { |
| 373 | 406 | await this._transferManager.prepareFile(diskPath, f.path) |
| 374 | 407 | } |
| … | … | @@ -377,6 +410,13 @@ export class DeployServer extends ReadyResource { |
| 377 | 410 | if ((i + 1) % 1000 === 0) this.emit('index-progress', i + 1, files.length) |
| 378 | 411 | } |
| 379 | 412 | |
| 413 | this.emit('scan-cache-stats', { |
| 414 | hits: _cacheHits, |
| 415 | misses: _cacheMisses, |
| 416 | hashedBytes: _hashedBytes, |
| 417 | totalFiles: files.length |
| 418 | }) |
| 419 | |
| 380 | 420 | this.manifest = { |
| 381 | 421 | files: files.map(f => ({ |
| 382 | 422 | path: f.path, |