@nitpicker/crawler 0.14.0 → 0.15.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.
Files changed (120) hide show
  1. package/lib/archive/archive.d.ts +98 -10
  2. package/lib/archive/archive.js +98 -9
  3. package/lib/archive/body-hash/compute-body-hash.d.ts +22 -0
  4. package/lib/archive/body-hash/compute-body-hash.js +31 -0
  5. package/lib/archive/body-hash/extract-body.d.ts +18 -0
  6. package/lib/archive/body-hash/extract-body.js +29 -0
  7. package/lib/archive/body-hash/mask-dynamic-ids.d.ts +33 -0
  8. package/lib/archive/body-hash/mask-dynamic-ids.js +44 -0
  9. package/lib/archive/body-hash/normalize-url-like-strings.d.ts +16 -0
  10. package/lib/archive/body-hash/normalize-url-like-strings.js +19 -0
  11. package/lib/archive/cache/clear-archive-cache-entry.d.ts +19 -0
  12. package/lib/archive/cache/clear-archive-cache-entry.js +25 -0
  13. package/lib/archive/cache/clear-archive-cache-root.d.ts +26 -0
  14. package/lib/archive/cache/clear-archive-cache-root.js +32 -0
  15. package/lib/archive/cache/list-archive-cache-entries.d.ts +23 -0
  16. package/lib/archive/cache/list-archive-cache-entries.js +125 -0
  17. package/lib/archive/cache/path-exists.d.ts +9 -0
  18. package/lib/archive/cache/path-exists.js +18 -0
  19. package/lib/archive/cache/types.d.ts +33 -0
  20. package/lib/archive/cache/types.js +1 -0
  21. package/lib/archive/create-adjunct-tables.d.ts +11 -0
  22. package/lib/archive/create-adjunct-tables.js +127 -0
  23. package/lib/archive/create-entity-tables.js +21 -1
  24. package/lib/archive/database.d.ts +37 -2
  25. package/lib/archive/database.js +50 -3
  26. package/lib/archive/db-ops/_shared/clear-write-ref-caches.js +1 -0
  27. package/lib/archive/db-ops/_shared/compress-payload.d.ts +26 -0
  28. package/lib/archive/db-ops/_shared/compress-payload.js +30 -0
  29. package/lib/archive/db-ops/_shared/create-write-ref-caches.js +1 -0
  30. package/lib/archive/db-ops/_shared/types.d.ts +2 -0
  31. package/lib/archive/db-ops/_shared/upsert-json-ref.js +3 -3
  32. package/lib/archive/db-ops/analysis/replace-page-templates.d.ts +12 -3
  33. package/lib/archive/db-ops/analysis/replace-page-templates.js +35 -3
  34. package/lib/archive/db-ops/analysis/types.d.ts +61 -0
  35. package/lib/archive/db-ops/analysis/types.js +1 -0
  36. package/lib/archive/db-ops/console-logs/compute-console-log-hash.d.ts +19 -0
  37. package/lib/archive/db-ops/console-logs/compute-console-log-hash.js +30 -0
  38. package/lib/archive/db-ops/console-logs/replace-console-logs.d.ts +34 -0
  39. package/lib/archive/db-ops/console-logs/replace-console-logs.js +123 -0
  40. package/lib/archive/db-ops/console-logs/stringify-console-log-args.d.ts +16 -0
  41. package/lib/archive/db-ops/console-logs/stringify-console-log-args.js +26 -0
  42. package/lib/archive/db-ops/console-logs/types.d.ts +38 -0
  43. package/lib/archive/db-ops/console-logs/types.js +1 -0
  44. package/lib/archive/db-ops/console-logs/upsert-console-log-item.d.ts +29 -0
  45. package/lib/archive/db-ops/console-logs/upsert-console-log-item.js +52 -0
  46. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.d.ts +11 -0
  47. package/lib/archive/db-ops/errors/list-dns-burned-host-candidates.js +21 -4
  48. package/lib/archive/db-ops/inventory/record-inventory-run.js +1 -0
  49. package/lib/archive/db-ops/lifecycle/init.d.ts +11 -2
  50. package/lib/archive/db-ops/lifecycle/init.js +21 -2
  51. package/lib/archive/db-ops/outages/close-network-outage.d.ts +12 -0
  52. package/lib/archive/db-ops/outages/close-network-outage.js +15 -0
  53. package/lib/archive/db-ops/outages/close-stale-open-network-outages.d.ts +26 -0
  54. package/lib/archive/db-ops/outages/close-stale-open-network-outages.js +42 -0
  55. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.d.ts +26 -0
  56. package/lib/archive/db-ops/outages/compute-outage-clamp-timestamp.js +31 -0
  57. package/lib/archive/db-ops/outages/insert-network-outage.d.ts +14 -0
  58. package/lib/archive/db-ops/outages/insert-network-outage.js +29 -0
  59. package/lib/archive/db-ops/outages/list-network-outages.d.ts +28 -0
  60. package/lib/archive/db-ops/outages/list-network-outages.js +42 -0
  61. package/lib/archive/db-ops/pages/reset/reset-failed-pages.d.ts +13 -0
  62. package/lib/archive/db-ops/pages/reset/reset-failed-pages.js +35 -7
  63. package/lib/archive/db-ops/pages/write/update-page.js +12 -0
  64. package/lib/archive/db-ops/pages/write/write-page-html-blob.js +6 -6
  65. package/lib/archive/filesystem/output-binary.d.ts +12 -0
  66. package/lib/archive/filesystem/output-binary.js +17 -0
  67. package/lib/archive/get-failed-page-messages.d.ts +20 -8
  68. package/lib/archive/get-failed-page-messages.js +29 -15
  69. package/lib/archive/migrate-content-items-alias-of-id.d.ts +47 -0
  70. package/lib/archive/migrate-content-items-alias-of-id.js +58 -0
  71. package/lib/archive/migrate-inventory-runs-invalid-skipped.d.ts +11 -0
  72. package/lib/archive/migrate-inventory-runs-invalid-skipped.js +24 -0
  73. package/lib/archive/migrate-page-meta-body-hash.d.ts +39 -0
  74. package/lib/archive/migrate-page-meta-body-hash.js +52 -0
  75. package/lib/archive/migrate-page-meta-console-error-count.d.ts +22 -0
  76. package/lib/archive/migrate-page-meta-console-error-count.js +34 -0
  77. package/lib/archive/page.d.ts +1 -1
  78. package/lib/archive/types.d.ts +47 -2
  79. package/lib/archive/url-alias/compute-tier-a-alias-key.d.ts +31 -0
  80. package/lib/archive/url-alias/compute-tier-a-alias-key.js +36 -0
  81. package/lib/archive/url-alias/compute-tier-b-alias-key.d.ts +19 -0
  82. package/lib/archive/url-alias/compute-tier-b-alias-key.js +30 -0
  83. package/lib/archive/url-alias/format-alias-key.d.ts +13 -0
  84. package/lib/archive/url-alias/format-alias-key.js +14 -0
  85. package/lib/archive/url-alias/parse-alias-key-parts.d.ts +15 -0
  86. package/lib/archive/url-alias/parse-alias-key-parts.js +40 -0
  87. package/lib/archive/url-alias/types.d.ts +7 -0
  88. package/lib/archive/url-alias/types.js +1 -0
  89. package/lib/crawler/choose-probe-host.d.ts +24 -0
  90. package/lib/crawler/choose-probe-host.js +38 -0
  91. package/lib/crawler/clear-dns-burned-host-cache.js +2 -0
  92. package/lib/crawler/crawler.js +257 -0
  93. package/lib/crawler/dns-burned-host-burn-timestamps.d.ts +17 -0
  94. package/lib/crawler/dns-burned-host-burn-timestamps.js +17 -0
  95. package/lib/crawler/evict-network-classified-destination-cache-entries.d.ts +26 -0
  96. package/lib/crawler/evict-network-classified-destination-cache-entries.js +34 -0
  97. package/lib/crawler/evict-outage-tainted-dns-burns.d.ts +23 -0
  98. package/lib/crawler/evict-outage-tainted-dns-burns.js +26 -0
  99. package/lib/crawler/network-gate.d.ts +49 -0
  100. package/lib/crawler/network-gate.js +78 -0
  101. package/lib/crawler/network-outage-detector.d.ts +51 -0
  102. package/lib/crawler/network-outage-detector.js +81 -0
  103. package/lib/crawler/network-outage-summary-counter.d.ts +21 -0
  104. package/lib/crawler/network-outage-summary-counter.js +18 -0
  105. package/lib/crawler/probe-network.d.ts +26 -0
  106. package/lib/crawler/probe-network.js +21 -0
  107. package/lib/crawler/types.d.ts +147 -1
  108. package/lib/crawler-orchestrator.d.ts +62 -18
  109. package/lib/crawler-orchestrator.js +161 -26
  110. package/lib/crawler.d.ts +21 -0
  111. package/lib/crawler.js +17 -0
  112. package/lib/is-within-outage-window.d.ts +49 -0
  113. package/lib/is-within-outage-window.js +33 -0
  114. package/lib/network-related-error-kinds.d.ts +23 -0
  115. package/lib/network-related-error-kinds.js +28 -0
  116. package/lib/types.d.ts +9 -1
  117. package/lib/utils/array/each-splitted.d.ts +1 -1
  118. package/lib/utils/compute-file-sha256.d.ts +17 -17
  119. package/lib/utils/compute-file-sha256.js +18 -47
  120. package/package.json +3 -3
@@ -0,0 +1,29 @@
1
+ import type { ConsoleLogItemRow } from './types.js';
2
+ import type { WriteRefCaches } from '../_shared/types.js';
3
+ import type { Knex } from 'knex';
4
+ /**
5
+ * Resolves the `console_log_items.id` for one console log entry's content,
6
+ * inserting the dictionary row when the hash is not yet known.
7
+ *
8
+ * Same no-op-update-for-`RETURNING` idiom as `upsertUrlRef` / `upsertJsonRef`:
9
+ * `INSERT ... ON CONFLICT(hash) DO UPDATE SET hash = hash RETURNING id`
10
+ * yields the existing row's id on a hash collision without a separate
11
+ * SELECT round trip, and (unlike `INSERT OR IGNORE ... RETURNING`) still
12
+ * returns a row when the insert itself is the no-op.
13
+ * @param qb - Knex instance or transaction connected to the archive DB.
14
+ * @param caches - The connection's write-side id caches; mutated in place.
15
+ * @param row - The content hash plus every resolved ref id / scalar column.
16
+ * @returns The `console_log_items.id` of the existing or newly inserted row.
17
+ * @example
18
+ * const id = await upsertConsoleLogItem(trx, caches, {
19
+ * hash: computeConsoleLogHash(entry),
20
+ * type: entry.type,
21
+ * textId,
22
+ * argsJsonId: null,
23
+ * locUrlId: null,
24
+ * locLine: null,
25
+ * locColumn: null,
26
+ * stackTextId: null,
27
+ * });
28
+ */
29
+ export declare function upsertConsoleLogItem(qb: Knex | Knex.Transaction, caches: WriteRefCaches, row: ConsoleLogItemRow): Promise<number>;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Resolves the `console_log_items.id` for one console log entry's content,
3
+ * inserting the dictionary row when the hash is not yet known.
4
+ *
5
+ * Same no-op-update-for-`RETURNING` idiom as `upsertUrlRef` / `upsertJsonRef`:
6
+ * `INSERT ... ON CONFLICT(hash) DO UPDATE SET hash = hash RETURNING id`
7
+ * yields the existing row's id on a hash collision without a separate
8
+ * SELECT round trip, and (unlike `INSERT OR IGNORE ... RETURNING`) still
9
+ * returns a row when the insert itself is the no-op.
10
+ * @param qb - Knex instance or transaction connected to the archive DB.
11
+ * @param caches - The connection's write-side id caches; mutated in place.
12
+ * @param row - The content hash plus every resolved ref id / scalar column.
13
+ * @returns The `console_log_items.id` of the existing or newly inserted row.
14
+ * @example
15
+ * const id = await upsertConsoleLogItem(trx, caches, {
16
+ * hash: computeConsoleLogHash(entry),
17
+ * type: entry.type,
18
+ * textId,
19
+ * argsJsonId: null,
20
+ * locUrlId: null,
21
+ * locLine: null,
22
+ * locColumn: null,
23
+ * stackTextId: null,
24
+ * });
25
+ */
26
+ export async function upsertConsoleLogItem(qb, caches, row) {
27
+ const hex = row.hash.toString('hex');
28
+ const cached = caches.consoleLogIds.get(hex);
29
+ if (cached !== undefined) {
30
+ return cached;
31
+ }
32
+ const rows = await qb.raw(`INSERT INTO console_log_items
33
+ (hash, type, text_id, args_json_id, loc_url_id, loc_line, loc_column, stack_text_id)
34
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
35
+ ON CONFLICT(hash) DO UPDATE SET hash = hash
36
+ RETURNING id`, [
37
+ row.hash,
38
+ row.type,
39
+ row.textId,
40
+ row.argsJsonId,
41
+ row.locUrlId,
42
+ row.locLine,
43
+ row.locColumn,
44
+ row.stackTextId,
45
+ ]);
46
+ const first = rows[0];
47
+ if (first === undefined) {
48
+ throw new Error('upsertConsoleLogItem: RETURNING yielded no row');
49
+ }
50
+ caches.consoleLogIds.set(hex, first.id);
51
+ return first.id;
52
+ }
@@ -16,6 +16,17 @@ import type { Knex } from 'knex';
16
16
  *
17
17
  * Returns `[]` on legacy archives that pre-date the `crawl_errors`
18
18
  * table — the `hasTable` guard keeps the call non-destructive.
19
+ *
20
+ * **Outage exclusion**: a host whose latest DNS error's `createdAt` falls
21
+ * inside a recorded `network_outages` window is dropped from the result
22
+ * regardless of the other checks. A host with no prior success this
23
+ * archive has ever seen (the case this whole exclusion-bag scheme cannot
24
+ * otherwise catch) would, without this check, get preload-seeded into
25
+ * `dnsBurnedHostCache` and short-circuit on EVERY subsequent session
26
+ * forever — the exact "damage 3" this function's caller
27
+ * (`#preloadDnsBurnedHostCache`) exists to prevent for hosts genuinely
28
+ * proven dead, not to inflict on hosts merely unlucky enough to be first
29
+ * contacted during an operator-side network blip.
19
30
  * @param knex - Knex query builder connected to the archive DB.
20
31
  * @returns Lower-cased hostnames safe to short-circuit.
21
32
  */
@@ -1,4 +1,6 @@
1
1
  import { classifyErrorKind } from '../../../classify-error-kind.js';
2
+ import { isWithinOutageWindow } from '../../../is-within-outage-window.js';
3
+ import { listNetworkOutages } from '../outages/list-network-outages.js';
2
4
  /**
3
5
  * Hostnames whose `crawl_errors` history is consistently DNS failures and
4
6
  * for which no recent 2xx-3xx page or resource is recorded — i.e. hosts
@@ -16,6 +18,17 @@ import { classifyErrorKind } from '../../../classify-error-kind.js';
16
18
  *
17
19
  * Returns `[]` on legacy archives that pre-date the `crawl_errors`
18
20
  * table — the `hasTable` guard keeps the call non-destructive.
21
+ *
22
+ * **Outage exclusion**: a host whose latest DNS error's `createdAt` falls
23
+ * inside a recorded `network_outages` window is dropped from the result
24
+ * regardless of the other checks. A host with no prior success this
25
+ * archive has ever seen (the case this whole exclusion-bag scheme cannot
26
+ * otherwise catch) would, without this check, get preload-seeded into
27
+ * `dnsBurnedHostCache` and short-circuit on EVERY subsequent session
28
+ * forever — the exact "damage 3" this function's caller
29
+ * (`#preloadDnsBurnedHostCache`) exists to prevent for hosts genuinely
30
+ * proven dead, not to inflict on hosts merely unlucky enough to be first
31
+ * contacted during an operator-side network blip.
19
32
  * @param knex - Knex query builder connected to the archive DB.
20
33
  * @returns Lower-cased hostnames safe to short-circuit.
21
34
  */
@@ -118,11 +131,12 @@ export async function listDnsBurnedHostCandidates(knex) {
118
131
  }
119
132
  resourceOkHosts.add(host);
120
133
  }
134
+ const outageWindows = await listNetworkOutages(knex);
121
135
  // A candidate host is burned only if neither pages nor resources hold a
122
- // 2xx-3xx for it, AND its latest 2xx page (if any) is not newer than
123
- // the latest DNS error. The third check guards against re-burning a
124
- // host that recovered between the last DNS failure and the most recent
125
- // crawl.
136
+ // 2xx-3xx for it, its latest 2xx page (if any) is not newer than the
137
+ // latest DNS error (guards against re-burning a host that recovered
138
+ // between the last DNS failure and the most recent crawl), AND that
139
+ // latest DNS error did not happen during a recorded network outage.
126
140
  const burned = [];
127
141
  for (const [host, latestErrorAt] of candidateLatestErrorAt) {
128
142
  if (pageOkHosts.has(host)) {
@@ -135,6 +149,9 @@ export async function listDnsBurnedHostCandidates(knex) {
135
149
  if (typeof latestOkAt === 'number' && latestOkAt > latestErrorAt) {
136
150
  continue;
137
151
  }
152
+ if (isWithinOutageWindow(latestErrorAt, outageWindows)) {
153
+ continue;
154
+ }
138
155
  burned.push(host);
139
156
  }
140
157
  return burned;
@@ -27,6 +27,7 @@ export async function recordInventoryRun(knex, meta) {
27
27
  new_pages: meta.new_pages ?? null,
28
28
  new_resources: meta.new_resources ?? null,
29
29
  scope_skipped: meta.scope_skipped ?? null,
30
+ invalid_skipped: meta.invalid_skipped ?? null,
30
31
  notes: meta.notes ?? null,
31
32
  })
32
33
  .returning('id');
@@ -2,7 +2,7 @@ import type { Knex } from 'knex';
2
2
  /**
3
3
  * Initializes the database schema if tables do not exist, then runs the
4
4
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
5
- * `page_meta.main_content_*`).
5
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
6
6
  *
7
7
  * There is deliberately no per-table *table-creation* migration chain here:
8
8
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -17,7 +17,16 @@ import type { Knex } from 'knex';
17
17
  * `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
18
18
  * additions to an existing 0.13 table are therefore the one case that still
19
19
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
20
- * `migrateMainContentsColumns`) rather than a DDL-string change alone.
20
+ * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
21
+ * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
22
+ * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
23
+ *
24
+ * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
25
+ * change) but belongs at this same boot phase for the same reason the
26
+ * others do: it must run before ANY reader (`resetFailedPages`,
27
+ * `listDnsBurnedHostCandidates`, …) can observe a `network_outages` row
28
+ * left `ended_at = NULL` by a crawl process that was killed mid-outage in a
29
+ * prior session.
21
30
  *
22
31
  * In read-only mode schema init + migration are SKIPPED so the same DB
23
32
  * can be opened safely by a viewer attached to a live (or interrupted)
@@ -1,12 +1,17 @@
1
1
  import { applyConnectionPragmas, initSchema } from '../../init-schema.js';
2
2
  import { assertCompatibleVersion } from '../../meta/assert-compatible-version.js';
3
+ import { migrateContentItemsAliasOfId } from '../../migrate-content-items-alias-of-id.js';
3
4
  import { migrateInfoMainContentSelector } from '../../migrate-info-main-content-selector.js';
4
5
  import { migrateInfoRoots } from '../../migrate-info-roots.js';
6
+ import { migrateInventoryRunsInvalidSkipped } from '../../migrate-inventory-runs-invalid-skipped.js';
5
7
  import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.js';
8
+ import { migratePageMetaBodyHash } from '../../migrate-page-meta-body-hash.js';
9
+ import { migratePageMetaConsoleErrorCount } from '../../migrate-page-meta-console-error-count.js';
10
+ import { closeStaleOpenNetworkOutages } from '../outages/close-stale-open-network-outages.js';
6
11
  /**
7
12
  * Initializes the database schema if tables do not exist, then runs the
8
13
  * remaining lightweight migrations (`info.roots`, `info.mainContentSelector`,
9
- * `page_meta.main_content_*`).
14
+ * `page_meta.main_content_*`, `inventory_runs.invalid_skipped`).
10
15
  *
11
16
  * There is deliberately no per-table *table-creation* migration chain here:
12
17
  * `assertCompatibleVersion` (called below, before any schema work) rejects
@@ -21,7 +26,16 @@ import { migrateMainContentsColumns } from '../../migrate-main-contents-columns.
21
26
  * `CREATE TABLE IF NOT EXISTS` is a no-op once the table is present. Column
22
27
  * additions to an existing 0.13 table are therefore the one case that still
23
28
  * needs an explicit `hasColumn`-guarded `ALTER TABLE` here (`migrateInfoRoots`,
24
- * `migrateMainContentsColumns`) rather than a DDL-string change alone.
29
+ * `migrateMainContentsColumns`, `migratePageMetaBodyHash`,
30
+ * `migratePageMetaConsoleErrorCount`, `migrateContentItemsAliasOfId`,
31
+ * `migrateInventoryRunsInvalidSkipped`) rather than a DDL-string change alone.
32
+ *
33
+ * `closeStaleOpenNetworkOutages` is not a schema migration (no columns
34
+ * change) but belongs at this same boot phase for the same reason the
35
+ * others do: it must run before ANY reader (`resetFailedPages`,
36
+ * `listDnsBurnedHostCandidates`, …) can observe a `network_outages` row
37
+ * left `ended_at = NULL` by a crawl process that was killed mid-outage in a
38
+ * prior session.
25
39
  *
26
40
  * In read-only mode schema init + migration are SKIPPED so the same DB
27
41
  * can be opened safely by a viewer attached to a live (or interrupted)
@@ -49,4 +63,9 @@ export async function init(knex, readOnly) {
49
63
  await migrateInfoRoots(knex);
50
64
  await migrateInfoMainContentSelector(knex);
51
65
  await migrateMainContentsColumns(knex);
66
+ await migratePageMetaBodyHash(knex);
67
+ await migratePageMetaConsoleErrorCount(knex);
68
+ await migrateContentItemsAliasOfId(knex);
69
+ await migrateInventoryRunsInvalidSkipped(knex);
70
+ await closeStaleOpenNetworkOutages(knex);
52
71
  }
@@ -0,0 +1,12 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Close an outage row by stamping `ended_at` — but ONLY if it is still
4
+ * open. The `whereNull('ended_at')` guard is what makes this idempotent: a
5
+ * second call (e.g. a duplicate recovery-probe success racing the first)
6
+ * matches zero rows and is a silent no-op, rather than overwriting an
7
+ * already-recorded `ended_at` with a later timestamp.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param id - The `network_outages.id` to close.
10
+ * @param endedAt - Epoch ms the outage is considered to have ended.
11
+ */
12
+ export declare function closeNetworkOutage(knex: Knex, id: number, endedAt: number): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Close an outage row by stamping `ended_at` — but ONLY if it is still
3
+ * open. The `whereNull('ended_at')` guard is what makes this idempotent: a
4
+ * second call (e.g. a duplicate recovery-probe success racing the first)
5
+ * matches zero rows and is a silent no-op, rather than overwriting an
6
+ * already-recorded `ended_at` with a later timestamp.
7
+ * @param knex - Knex query builder connected to the archive DB.
8
+ * @param id - The `network_outages.id` to close.
9
+ * @param endedAt - Epoch ms the outage is considered to have ended.
10
+ */
11
+ export async function closeNetworkOutage(knex, id, endedAt) {
12
+ await knex('network_outages').where({ id }).whereNull('ended_at').update({
13
+ ended_at: endedAt,
14
+ });
15
+ }
@@ -0,0 +1,26 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Durably close every `network_outages` row still `ended_at = NULL` at the
4
+ * start of a writer session.
5
+ *
6
+ * A row is left open only when the crawl process was killed (Ctrl-C / OOM
7
+ * / SIGKILL) mid-outage, before a recovery probe could close it. Called
8
+ * once from `db-ops/lifecycle/init.ts` — right after `initSchema` (which
9
+ * guarantees the table exists) and before anything else touches
10
+ * `network_outages` — so that by the time `resetFailedPages` /
11
+ * `listDnsBurnedHostCandidates` / any other reader runs, no row can still
12
+ * be open from a PRIOR session. (A row opened by THIS session cannot exist
13
+ * yet at this point in the boot sequence — the sliding-window detector
14
+ * only starts once the crawl loop begins.)
15
+ *
16
+ * This complements, but does not replace, `list-network-outages.ts`'s
17
+ * on-the-fly clamp: that read-side resolution stays in place as a
18
+ * defensive fallback, but after this runs there should be nothing left for
19
+ * it to resolve.
20
+ *
21
+ * Idempotent: a row closed by a previous call (or by a normal
22
+ * recovery-probe success) is simply absent from the `whereNull('ended_at')`
23
+ * scan, so re-running this on every writer open is always safe.
24
+ * @param knex - Knex query builder connected to the archive DB.
25
+ */
26
+ export declare function closeStaleOpenNetworkOutages(knex: Knex): Promise<void>;
@@ -0,0 +1,42 @@
1
+ import { closeNetworkOutage } from './close-network-outage.js';
2
+ import { computeOutageClampTimestamp } from './compute-outage-clamp-timestamp.js';
3
+ /**
4
+ * Durably close every `network_outages` row still `ended_at = NULL` at the
5
+ * start of a writer session.
6
+ *
7
+ * A row is left open only when the crawl process was killed (Ctrl-C / OOM
8
+ * / SIGKILL) mid-outage, before a recovery probe could close it. Called
9
+ * once from `db-ops/lifecycle/init.ts` — right after `initSchema` (which
10
+ * guarantees the table exists) and before anything else touches
11
+ * `network_outages` — so that by the time `resetFailedPages` /
12
+ * `listDnsBurnedHostCandidates` / any other reader runs, no row can still
13
+ * be open from a PRIOR session. (A row opened by THIS session cannot exist
14
+ * yet at this point in the boot sequence — the sliding-window detector
15
+ * only starts once the crawl loop begins.)
16
+ *
17
+ * This complements, but does not replace, `list-network-outages.ts`'s
18
+ * on-the-fly clamp: that read-side resolution stays in place as a
19
+ * defensive fallback, but after this runs there should be nothing left for
20
+ * it to resolve.
21
+ *
22
+ * Idempotent: a row closed by a previous call (or by a normal
23
+ * recovery-probe success) is simply absent from the `whereNull('ended_at')`
24
+ * scan, so re-running this on every writer open is always safe.
25
+ * @param knex - Knex query builder connected to the archive DB.
26
+ */
27
+ export async function closeStaleOpenNetworkOutages(knex) {
28
+ const hasTable = await knex.schema.hasTable('network_outages');
29
+ if (!hasTable) {
30
+ return;
31
+ }
32
+ const openRows = (await knex('network_outages')
33
+ .whereNull('ended_at')
34
+ .select('id', 'started_at'));
35
+ if (openRows.length === 0) {
36
+ return;
37
+ }
38
+ const clamp = await computeOutageClampTimestamp(knex);
39
+ for (const row of openRows) {
40
+ await closeNetworkOutage(knex, row.id, Math.max(clamp, row.started_at));
41
+ }
42
+ }
@@ -0,0 +1,26 @@
1
+ import type { Knex } from 'knex';
2
+ /**
3
+ * Compute the timestamp used to resolve an outage row whose `ended_at` is
4
+ * still `NULL` (the crawl process was killed before a recovery probe could
5
+ * close it) into a concrete, bounded window.
6
+ *
7
+ * The archive's own most-recent observations — the latest `crawl_errors`
8
+ * timestamp and the latest `content_items.last_crawled_at` — are the only
9
+ * evidence available for "when did activity in this archive last happen",
10
+ * so the later of the two stands in for "the outage cannot have lasted
11
+ * past this point, because the archive shows activity here". Using the
12
+ * LARGER of the two (not just `crawl_errors`) matters because a session
13
+ * that crashed mid-outage may have its last successful page write be more
14
+ * recent than its last recorded error, or vice versa.
15
+ *
16
+ * Both source tables are guaranteed to exist by the time this runs — this
17
+ * helper is only ever called from writer-context code (see
18
+ * `list-network-outages.ts` / the boot-time stale-open finalizer), and
19
+ * `initSchema` creates both `crawl_errors` and `content_items` before any
20
+ * writer session's first query.
21
+ * @param knex - Knex query builder connected to the archive DB.
22
+ * @returns The larger of `MAX(crawl_errors.createdAt)` and
23
+ * `MAX(content_items.last_crawled_at)`, or `0` if the archive has neither
24
+ * (a brand-new archive with no activity yet).
25
+ */
26
+ export declare function computeOutageClampTimestamp(knex: Knex): Promise<number>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Compute the timestamp used to resolve an outage row whose `ended_at` is
3
+ * still `NULL` (the crawl process was killed before a recovery probe could
4
+ * close it) into a concrete, bounded window.
5
+ *
6
+ * The archive's own most-recent observations — the latest `crawl_errors`
7
+ * timestamp and the latest `content_items.last_crawled_at` — are the only
8
+ * evidence available for "when did activity in this archive last happen",
9
+ * so the later of the two stands in for "the outage cannot have lasted
10
+ * past this point, because the archive shows activity here". Using the
11
+ * LARGER of the two (not just `crawl_errors`) matters because a session
12
+ * that crashed mid-outage may have its last successful page write be more
13
+ * recent than its last recorded error, or vice versa.
14
+ *
15
+ * Both source tables are guaranteed to exist by the time this runs — this
16
+ * helper is only ever called from writer-context code (see
17
+ * `list-network-outages.ts` / the boot-time stale-open finalizer), and
18
+ * `initSchema` creates both `crawl_errors` and `content_items` before any
19
+ * writer session's first query.
20
+ * @param knex - Knex query builder connected to the archive DB.
21
+ * @returns The larger of `MAX(crawl_errors.createdAt)` and
22
+ * `MAX(content_items.last_crawled_at)`, or `0` if the archive has neither
23
+ * (a brand-new archive with no activity yet).
24
+ */
25
+ export async function computeOutageClampTimestamp(knex) {
26
+ const [crawlErrorsRow] = (await knex('crawl_errors').max('createdAt as maxCreatedAt'));
27
+ const [contentItemsRow] = (await knex('content_items').max('last_crawled_at as maxLastCrawledAt'));
28
+ const latestError = crawlErrorsRow?.maxCreatedAt ?? 0;
29
+ const latestCrawl = contentItemsRow?.maxLastCrawledAt ?? 0;
30
+ return Math.max(latestError, latestCrawl);
31
+ }
@@ -0,0 +1,14 @@
1
+ import type { InsertNetworkOutageParams } from '../../types.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * Appends one row to the `network_outages` journal, with `ended_at` left
5
+ * `NULL` — the row starts life as an open outage.
6
+ *
7
+ * Called the moment a recovery probe CONFIRMS a suspect outage (i.e. the
8
+ * probe itself failed, not merely the sliding-window threshold trip). See
9
+ * `close-network-outage.ts` for how the row is later closed.
10
+ * @param knex - Knex query builder connected to the archive DB.
11
+ * @param params - The confirmed-outage fields to record.
12
+ * @returns The autoincremented `id` of the newly-inserted row.
13
+ */
14
+ export declare function insertNetworkOutage(knex: Knex, params: InsertNetworkOutageParams): Promise<number>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Appends one row to the `network_outages` journal, with `ended_at` left
3
+ * `NULL` — the row starts life as an open outage.
4
+ *
5
+ * Called the moment a recovery probe CONFIRMS a suspect outage (i.e. the
6
+ * probe itself failed, not merely the sliding-window threshold trip). See
7
+ * `close-network-outage.ts` for how the row is later closed.
8
+ * @param knex - Knex query builder connected to the archive DB.
9
+ * @param params - The confirmed-outage fields to record.
10
+ * @returns The autoincremented `id` of the newly-inserted row.
11
+ */
12
+ export async function insertNetworkOutage(knex, params) {
13
+ const inserted = await knex
14
+ .from('network_outages')
15
+ .insert({
16
+ started_at: params.startedAt,
17
+ detected_at: params.detectedAt,
18
+ ended_at: null,
19
+ probe_host: params.probeHost,
20
+ trigger_error_count: params.triggerErrorCount,
21
+ trigger_host_count: params.triggerHostCount,
22
+ })
23
+ .returning('id');
24
+ const id = inserted[0]?.id;
25
+ if (typeof id !== 'number') {
26
+ throw new TypeError('insertNetworkOutage: INSERT returned no row id');
27
+ }
28
+ return id;
29
+ }
@@ -0,0 +1,28 @@
1
+ import type { OutageWindow } from '../../../is-within-outage-window.js';
2
+ import type { Knex } from 'knex';
3
+ /**
4
+ * List every recorded outage as a resolved (closed) {@link OutageWindow},
5
+ * suitable for `isWithinOutageWindow`.
6
+ *
7
+ * This is the crawler-internal counterpart to `@nitpicker/query`'s richer
8
+ * `listNetworkOutages` (full row shape, for CLI/MCP display) — this one
9
+ * exists purely to feed the three write-path consumers
10
+ * (`resetFailedPages`, `listDnsBurnedHostCandidates`, the gate itself) that
11
+ * only need "was this timestamp inside an outage", never the row's other
12
+ * columns.
13
+ *
14
+ * Any row whose `ended_at` is still `NULL` (the crawl process was killed
15
+ * before recovery) is resolved on the fly via
16
+ * {@link computeOutageClampTimestamp} rather than ever being returned as an
17
+ * unbounded window — see `is-within-outage-window.ts`'s `OutageWindow`
18
+ * docstring for why an open-ended window would be a correctness bug (every
19
+ * later error would retroactively read as network-caused, forever). This
20
+ * on-the-fly resolution does NOT persist to the row — it is a defensive
21
+ * fallback independent of whichever boot-time finalizer durably closes
22
+ * stale-open rows.
23
+ * @param knex - Knex query builder connected to the archive DB.
24
+ * @returns Resolved outage windows. Empty when the archive predates the
25
+ * `network_outages` table (self-healed on next writer open, so this is
26
+ * never a permanent state) or has recorded no outages.
27
+ */
28
+ export declare function listNetworkOutages(knex: Knex): Promise<OutageWindow[]>;
@@ -0,0 +1,42 @@
1
+ import { computeOutageClampTimestamp } from './compute-outage-clamp-timestamp.js';
2
+ /**
3
+ * List every recorded outage as a resolved (closed) {@link OutageWindow},
4
+ * suitable for `isWithinOutageWindow`.
5
+ *
6
+ * This is the crawler-internal counterpart to `@nitpicker/query`'s richer
7
+ * `listNetworkOutages` (full row shape, for CLI/MCP display) — this one
8
+ * exists purely to feed the three write-path consumers
9
+ * (`resetFailedPages`, `listDnsBurnedHostCandidates`, the gate itself) that
10
+ * only need "was this timestamp inside an outage", never the row's other
11
+ * columns.
12
+ *
13
+ * Any row whose `ended_at` is still `NULL` (the crawl process was killed
14
+ * before recovery) is resolved on the fly via
15
+ * {@link computeOutageClampTimestamp} rather than ever being returned as an
16
+ * unbounded window — see `is-within-outage-window.ts`'s `OutageWindow`
17
+ * docstring for why an open-ended window would be a correctness bug (every
18
+ * later error would retroactively read as network-caused, forever). This
19
+ * on-the-fly resolution does NOT persist to the row — it is a defensive
20
+ * fallback independent of whichever boot-time finalizer durably closes
21
+ * stale-open rows.
22
+ * @param knex - Knex query builder connected to the archive DB.
23
+ * @returns Resolved outage windows. Empty when the archive predates the
24
+ * `network_outages` table (self-healed on next writer open, so this is
25
+ * never a permanent state) or has recorded no outages.
26
+ */
27
+ export async function listNetworkOutages(knex) {
28
+ const hasTable = await knex.schema.hasTable('network_outages');
29
+ if (!hasTable) {
30
+ return [];
31
+ }
32
+ const rows = (await knex('network_outages').select('started_at', 'ended_at'));
33
+ if (rows.length === 0) {
34
+ return [];
35
+ }
36
+ const hasOpenRow = rows.some((row) => row.ended_at === null);
37
+ const clamp = hasOpenRow ? await computeOutageClampTimestamp(knex) : 0;
38
+ return rows.map((row) => ({
39
+ startedAt: row.started_at,
40
+ endedAt: row.ended_at ?? Math.max(clamp, row.started_at),
41
+ }));
42
+ }
@@ -29,6 +29,19 @@ import type { Knex } from 'knex';
29
29
  * target shrinking across `--retry-failed` passes by leaving deterministic
30
30
  * dead-ends alone.
31
31
  *
32
+ * **Outage override**: before applying the permanent-kind exclusion, the
33
+ * message's `createdAt` is checked against every recorded
34
+ * `network_outages` window (see `is-within-outage-window.ts`). A `dns` (or
35
+ * any other permanent-kind) failure whose timestamp falls inside a window
36
+ * is treated as retryable regardless — `dns` is only a permanent,
37
+ * site-specific verdict when nothing else explains it; inside a confirmed
38
+ * operator-network outage, the same `getaddrinfo ENOTFOUND` message is
39
+ * evidence about the CRAWLER's connectivity, not the target site, and
40
+ * excluding it from retry would strand a perfectly reachable host as a
41
+ * false permanent failure for the rest of the archive's life. An archive
42
+ * with no recorded outages (`listNetworkOutages` returns `[]`) behaves
43
+ * exactly as before this override existed.
44
+ *
32
45
  * Matching rows — internal and external alike — are demoted back to pending
33
46
  * (`scraped = 0`) and have their stale scrape metadata cleared (the
34
47
  * `page_meta` row is deleted outright rather than nulled column-by-column).
@@ -1,7 +1,9 @@
1
1
  import { classifyErrorKind } from '../../../../classify-error-kind.js';
2
+ import { isWithinOutageWindow } from '../../../../is-within-outage-window.js';
2
3
  import { PERMANENT_ERROR_KINDS } from '../../../../permanent-error-kinds.js';
3
4
  import { dbLog } from '../../../debug.js';
4
5
  import { getFailedPageMessages } from '../../../get-failed-page-messages.js';
6
+ import { listNetworkOutages } from '../../outages/list-network-outages.js';
5
7
  /**
6
8
  * Reset previously-attempted pages that ended in a recoverable failure so a
7
9
  * follow-up crawl can re-fetch them from scratch.
@@ -32,6 +34,19 @@ import { getFailedPageMessages } from '../../../get-failed-page-messages.js';
32
34
  * target shrinking across `--retry-failed` passes by leaving deterministic
33
35
  * dead-ends alone.
34
36
  *
37
+ * **Outage override**: before applying the permanent-kind exclusion, the
38
+ * message's `createdAt` is checked against every recorded
39
+ * `network_outages` window (see `is-within-outage-window.ts`). A `dns` (or
40
+ * any other permanent-kind) failure whose timestamp falls inside a window
41
+ * is treated as retryable regardless — `dns` is only a permanent,
42
+ * site-specific verdict when nothing else explains it; inside a confirmed
43
+ * operator-network outage, the same `getaddrinfo ENOTFOUND` message is
44
+ * evidence about the CRAWLER's connectivity, not the target site, and
45
+ * excluding it from retry would strand a perfectly reachable host as a
46
+ * false permanent failure for the rest of the archive's life. An archive
47
+ * with no recorded outages (`listNetworkOutages` returns `[]`) behaves
48
+ * exactly as before this override existed.
49
+ *
35
50
  * Matching rows — internal and external alike — are demoted back to pending
36
51
  * (`scraped = 0`) and have their stale scrape metadata cleared (the
37
52
  * `page_meta` row is deleted outright rather than nulled column-by-column).
@@ -69,16 +84,29 @@ export async function resetFailedPages(knex) {
69
84
  }
70
85
  const candidateIds = candidates.map((row) => row.id);
71
86
  const candidateUrls = candidates.map((row) => row.url);
72
- const messages = await getFailedPageMessages(knex, candidateIds, candidateUrls);
73
- // Drop candidates whose latest recorded message classifies as permanent.
74
- // An empty/absent message stays in the retry pool — we keep retrying when
75
- // we don't know it's permanent, erring on the side of investigation.
87
+ // Unrelated tables (page_errors/crawl_errors vs network_outages), no data
88
+ // dependency between them run concurrently instead of paying two
89
+ // sequential round-trips on every `--retry-failed` pass.
90
+ const [messages, outageWindows] = await Promise.all([
91
+ getFailedPageMessages(knex, candidateIds, candidateUrls),
92
+ listNetworkOutages(knex),
93
+ ]);
94
+ // Drop candidates whose latest recorded message classifies as permanent —
95
+ // UNLESS that message's timestamp falls inside a recorded network outage,
96
+ // in which case the permanent-kind verdict is overridden (see the
97
+ // "Outage override" section of this function's docstring). An
98
+ // empty/absent message stays in the retry pool regardless — we keep
99
+ // retrying when we don't know it's permanent, erring on the side of
100
+ // investigation.
76
101
  const retryable = candidates.filter((row) => {
77
- const message = messages.get(row.id) ?? '';
78
- if (message === '') {
102
+ const resolved = messages.get(row.id);
103
+ if (resolved === undefined || resolved.message === '') {
104
+ return true;
105
+ }
106
+ if (!PERMANENT_ERROR_KINDS.has(classifyErrorKind(resolved.message))) {
79
107
  return true;
80
108
  }
81
- return !PERMANENT_ERROR_KINDS.has(classifyErrorKind(message));
109
+ return isWithinOutageWindow(resolved.createdAt, outageWindows);
82
110
  });
83
111
  const excludedCount = candidates.length - retryable.length;
84
112
  if (excludedCount > 0) {
@@ -1,6 +1,7 @@
1
1
  import { tryParseUrl as parseUrl } from '@d-zero/shared/parse-url';
2
2
  import { isHtmlContentType } from '../../../../crawler/is-html-content-type.js';
3
3
  import { eachSplitted } from '../../../../utils/array/each-splitted.js';
4
+ import { computeBodyHash } from '../../../body-hash/compute-body-hash.js';
4
5
  import { dbLog } from '../../../debug.js';
5
6
  import { deriveLineageFromParent } from '../../../derive-lineage-from-parent.js';
6
7
  import { matchImagesToDomPaths } from '../../../populate-entity-tables/match-images-to-dom-paths.js';
@@ -141,6 +142,12 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
141
142
  // content check alone expresses the intent without a redundant term.
142
143
  if (writeHtml && page.html.length > 0) {
143
144
  await writePageHtmlBlob(pageId, page.html, trx);
145
+ // Computed from the same `page.html` written above, in the same
146
+ // transaction, so `page_meta.body_hash` never observably lags the
147
+ // snapshot it was derived from.
148
+ await trx('page_meta')
149
+ .where('page_id', pageId)
150
+ .update({ body_hash: computeBodyHash(page.html) });
144
151
  }
145
152
  else if (writeHtml &&
146
153
  page.contentType !== null &&
@@ -155,7 +162,12 @@ async function updatePageInTransaction(trx, knex, caches, page, destUrlObject, s
155
162
  // stale ref can only have been written by a snapshot-capable call
156
163
  // (`setPage`); `setExternalPage` passes `writeHtml = false` and never
157
164
  // sets `html`, so it has nothing to clear.
165
+ //
166
+ // `page_meta.body_hash` is cleared alongside it for the same reason —
167
+ // it is derived from the same HTML `page_html_ref` points at, so it
168
+ // must not outlive the ref it was computed from.
158
169
  await trx('page_html_ref').where('page_id', pageId).delete();
170
+ await trx('page_meta').where('page_id', pageId).update({ body_hash: null });
159
171
  }
160
172
  // Re-scrape semantics: the same URL can be scraped more than once
161
173
  // (e.g. `crawl --resume`, re-visits, `--append` re-promotion). Edges