@cloudbitmaps/roaring 0.15.0 → 0.17.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.
@@ -21,6 +21,10 @@ import {
21
21
  NOOP_METRICS,
22
22
  RetryingStorageChunkSource,
23
23
  SegmentEngine,
24
+ LEASE_SKEW_MS,
25
+ LeaseExpiredError,
26
+ MAX_LEASE_MS,
27
+ NotFoundError,
24
28
  UnsupportedError,
25
29
  ValidationError as ValidationError2,
26
30
  WriteConflictError,
@@ -33,17 +37,21 @@ import {
33
37
  estimateCost,
34
38
  groundedReport,
35
39
  loadSegment,
40
+ loadSegmentChunks,
36
41
  mapWithConcurrency,
37
42
  resolveBudget,
38
43
  resolvePerOpBudget,
44
+ reapRegistryTombstones,
39
45
  retireExpired,
40
46
  runConsistencyCheck,
41
47
  runExport,
42
48
  setSegmentRetention,
43
49
  clearSegmentRetention,
44
50
  getSegmentRetention,
51
+ releaseLease,
45
52
  safeMetrics,
46
- splitId
53
+ splitId,
54
+ takeLease
47
55
  } from "@cloudbitmaps/core";
48
56
  import { validateSegmentRef } from "@cloudbitmaps/core/driver-kit";
49
57
 
@@ -103,7 +111,7 @@ function refuseReservedNamespace(namespace) {
103
111
 
104
112
  // src/roaring-codec.ts
105
113
  import roaring from "roaring";
106
- import { IntegrityError as IntegrityError2 } from "@cloudbitmaps/core";
114
+ import { IntegrityError as IntegrityError2, decodeSerialized } from "@cloudbitmaps/core";
107
115
 
108
116
  // src/portable/layout.ts
109
117
  import { IntegrityError } from "@cloudbitmaps/core";
@@ -351,6 +359,10 @@ var SafeBitmap = class _SafeBitmap {
351
359
  throw new IntegrityError2(`failed to deserialize bitmap: ${err.message}`);
352
360
  }
353
361
  }
362
+ /** The `roaring` bitmap this wraps, for a caller that was handed it once and keeps no wrapper. */
363
+ unwrap() {
364
+ return this.bitmap;
365
+ }
354
366
  serialize() {
355
367
  return this.bitmap.serialize(SerializationFormat.portable);
356
368
  }
@@ -460,7 +472,8 @@ function bitmapAsLoadInput(input) {
460
472
  var roaringCodec = {
461
473
  empty: () => SafeBitmap.empty(),
462
474
  fromValues: (values) => SafeBitmap.fromValues(values),
463
- safeDeserialize: (bytes, maxBytes, options) => SafeBitmap.safeDeserialize(bytes, maxBytes, options)
475
+ safeDeserialize: (bytes, maxBytes, options) => SafeBitmap.safeDeserialize(bytes, maxBytes, options),
476
+ owns: (bitmap) => bitmap instanceof SafeBitmap
464
477
  };
465
478
 
466
479
  // src/system-clock.ts
@@ -514,6 +527,86 @@ var OPTION_KEYS = {
514
527
  seams: ["clock", "rng"]
515
528
  };
516
529
 
530
+ // src/lease-guards.ts
531
+ var guardIdIterable = (inner, check) => ({
532
+ [Symbol.asyncIterator]: () => {
533
+ const it = inner[Symbol.asyncIterator]();
534
+ let chunk = -1;
535
+ let first = true;
536
+ const stop = async (err) => {
537
+ await it.return?.();
538
+ throw err;
539
+ };
540
+ return {
541
+ async next() {
542
+ if (first) {
543
+ first = false;
544
+ try {
545
+ check();
546
+ } catch (err) {
547
+ return stop(err);
548
+ }
549
+ }
550
+ const r = await it.next();
551
+ if (r.done === true) return r;
552
+ const key = r.value >>> 16;
553
+ if (key !== chunk) {
554
+ try {
555
+ check();
556
+ } catch (err) {
557
+ return stop(err);
558
+ }
559
+ chunk = key;
560
+ }
561
+ return r;
562
+ },
563
+ async return() {
564
+ await it.return?.();
565
+ return { done: true, value: void 0 };
566
+ }
567
+ };
568
+ }
569
+ });
570
+ var guardIds = (inner, check) => ({
571
+ [Symbol.asyncIterator]: () => guardIdIterable(inner, check)[Symbol.asyncIterator](),
572
+ batches: () => guardItems(inner.batches(), check)
573
+ });
574
+ var guardItems = (inner, check) => ({
575
+ [Symbol.asyncIterator]: () => {
576
+ const it = inner[Symbol.asyncIterator]();
577
+ let first = true;
578
+ const stop = async (err) => {
579
+ await it.return?.();
580
+ throw err;
581
+ };
582
+ return {
583
+ async next() {
584
+ if (first) {
585
+ first = false;
586
+ try {
587
+ check();
588
+ } catch (err) {
589
+ return stop(err);
590
+ }
591
+ }
592
+ const r = await it.next();
593
+ if (r.done === true) return r;
594
+ try {
595
+ check();
596
+ } catch (err) {
597
+ return stop(err);
598
+ }
599
+ return r;
600
+ },
601
+ async return() {
602
+ await it.return?.();
603
+ return { done: true, value: void 0 };
604
+ }
605
+ };
606
+ }
607
+ });
608
+ var guardChunks = (inner, check) => guardItems(inner, check);
609
+
517
610
  // src/index.ts
518
611
  import {
519
612
  MemoryStorage,
@@ -524,6 +617,9 @@ import {
524
617
  excludingReservedRows as excludingReservedRows2,
525
618
  readRetentionPolicy,
526
619
  MIN_EXPIRES_AT_MS as MIN_EXPIRES_AT_MS2,
620
+ LEASE_SKEW_MS as LEASE_SKEW_MS2,
621
+ MAX_LEASE_MS as MAX_LEASE_MS2,
622
+ MAX_LEASES_PER_SEGMENT,
527
623
  InProcessKeystore,
528
624
  NodeAead,
529
625
  aadFor,
@@ -531,18 +627,22 @@ import {
531
627
  ValidationError as ValidationError3,
532
628
  WriteConflictError as WriteConflictError2,
533
629
  IntegrityError as IntegrityError3,
534
- NotFoundError,
630
+ NotFoundError as NotFoundError2,
535
631
  UnsupportedError as UnsupportedError2,
536
632
  CapabilityError,
537
633
  TransientError,
538
634
  KeyUnavailableError,
539
635
  BudgetExceededError,
636
+ LeaseExpiredError as LeaseExpiredError2,
637
+ LeaseLimitError,
540
638
  isCloudRoaringError,
541
639
  isWriteConflictError,
542
640
  isTransientError,
543
641
  isNotFoundError,
544
642
  isIntegrityError,
545
643
  isValidationError,
644
+ isLeaseExpiredError,
645
+ isLeaseLimitError,
546
646
  CrbmReader,
547
647
  BufferReader,
548
648
  CountingMetricsSink,
@@ -738,6 +838,9 @@ var CloudRoaring = class _CloudRoaring {
738
838
  clock
739
839
  });
740
840
  const resolved = resolveStorageSource(options, clock);
841
+ if (metrics !== NOOP_METRICS && isStorageBackend(options.storage)) {
842
+ options.storage.attachMetrics?.(metrics);
843
+ }
741
844
  let storage = resolved.source;
742
845
  if (options.retry !== false) {
743
846
  const { onRetry: userOnRetry, ...ov } = options.retry ?? {};
@@ -835,7 +938,7 @@ var CloudRoaring = class _CloudRoaring {
835
938
  clock: this.clock,
836
939
  metrics: this.metrics,
837
940
  materialize: (dest, ids, op, options2) => this.materialize(dest, ids, op, options2),
838
- pinned: (r, e) => this.pinSegment(r, e),
941
+ pinned: (r, e, l, at) => this.pinSegment(r, e, l, at),
839
942
  combineEngine: (handles) => this.engineForCombine(handles),
840
943
  expiresAt
841
944
  });
@@ -885,7 +988,7 @@ var CloudRoaring = class _CloudRoaring {
885
988
  const metadata = options?.metadata;
886
989
  let result;
887
990
  try {
888
- result = await loadSegment(dest, ids, deps, {
991
+ const loadOptions = {
889
992
  ...options?.allowEmpty === void 0 ? {} : { allowEmpty: options.allowEmpty },
890
993
  ...options?.guard === void 0 ? {} : { guard: options.guard },
891
994
  ...metadata === void 0 ? {} : { metadata },
@@ -898,7 +1001,8 @@ var CloudRoaring = class _CloudRoaring {
898
1001
  // as a side effect of adding a guard whose entire purpose is preventing data loss. Opt in with `keep`.
899
1002
  keep: options?.keep ?? KEEP_EVERY_GENERATION,
900
1003
  ...options?.audit === void 0 ? {} : { audit: options.audit }
901
- });
1004
+ };
1005
+ result = ids instanceof CombineChunks ? await loadSegmentChunks(dest, ids.chunks, deps, loadOptions) : await loadSegment(dest, ids, deps, loadOptions);
902
1006
  } finally {
903
1007
  this.engine.invalidate(dest);
904
1008
  }
@@ -1259,24 +1363,26 @@ var CloudRoaring = class _CloudRoaring {
1259
1363
  * A refused load deletes the object it wrote before returning — it sits above `currentGen`, where generation
1260
1364
  * collection deliberately never looks — but only while the segment's registry row is unchanged or gone. Once
1261
1365
  * another write has changed the row, the generation number it holds may name another incarnation's live object,
1262
- * so it leaves the orphan rather than risk deleting live data. The orphan is an ordinary generation once a later
1263
- * one is current above it, and collection counts it within `keep`.
1366
+ * so it leaves the orphan rather than risk deleting live data. No row's list names it, so it takes no place in a
1367
+ * window, and a listing deletes it once a later generation is current above it.
1264
1368
  *
1265
- * **Collection is by name for the default `keep`.** With `keep` of 0 or 1, a load that found nothing above the
1266
- * pointer deletes the one generation its publish pushed out of the window and lists nothing; it lists the segment's
1267
- * objects on every sixteenth generation, and on any load that met an object above the pointer or whose check found
1268
- * the current generation's object gone, to take what the name-only passes leave, such as the generations an
1269
- * earlier, wider `keep` held. `keep` of 2 or more lists on every load. {@link LoadResult.collected} then names what
1270
- * the pass deleted by name, and that generation may have been gone already.
1369
+ * **Collection is by name at any `keep` up to 64.** The segment's row records the generations a load keeps, so a load
1370
+ * that found nothing above the pointer deletes the generations its publish pushed out of the window and lists
1371
+ * nothing. It lists the segment's objects on every sixteenth generation, and on any load that met an object above the
1372
+ * pointer or whose check found the current generation's object gone, to take what the name-only passes leave, such as
1373
+ * an object a crashed load left below the pointer. A `keep` above 64 records no list and lists on every load, and so
1374
+ * does the first load of a row that records none, which then records it. {@link LoadResult.collected} then names
1375
+ * what the pass deleted by name, and one name may have been gone already.
1271
1376
  *
1272
1377
  * What it **throws** for is a fault rather than an outcome: invalid options or ids, and a crypto-shredded
1273
1378
  * segment (`ValidationError`); a key the keystore cannot provide (`KeyUnavailableError`); a current generation
1274
1379
  * that will not open when a guard has to read its size, which it does only when the row has no usable summary of it
1275
- * (`IntegrityError`); a driver failure; and a collection pass
1276
- * by listing that could not prove the segment was still the same one (`WriteConflictError`). That one, and a
1277
- * failure in the collection's own reads or deletes, can be raised **after** the publish already landed, so a throw
1278
- * does not by itself mean the load did not take effect — re-read the pointer rather than assuming. A collection by
1279
- * name that finds the segment changed returns an empty `collected` instead.
1380
+ * (`IntegrityError`); a driver failure; and a fault in the collection's own reads, listing or deletes, or in the write
1381
+ * that records the window (anything but a lost race). A collection fault can be raised **after** the publish already
1382
+ * landed, so a throw does not by itself mean the load did not take effect — re-read the pointer rather than assuming.
1383
+ * A collection, by name or by listing, that finds another writer has moved the row since the publish (a load, a
1384
+ * rollback, a purge) stops and returns what it deleted in `collected`: a load that took effect does not throw for a
1385
+ * race it won.
1280
1386
  *
1281
1387
  * **A `TransientError` from the registry write can leave the publish unsettled, and deletes nothing.** The
1282
1388
  * generation's object is sent again after a throttle where the backend allows it (a write id tells a first send
@@ -1348,7 +1454,11 @@ var CloudRoaring = class _CloudRoaring {
1348
1454
  validateSegmentRef(ref);
1349
1455
  const deps = this.lifecycleDeps("dropSegment");
1350
1456
  try {
1351
- return await dropSegment(ref, { registry: deps.registry, storage: deps.storage }, options);
1457
+ return await dropSegment(
1458
+ ref,
1459
+ { registry: deps.registry, storage: deps.storage, clock: deps.clock, rng: deps.rng },
1460
+ options
1461
+ );
1352
1462
  } finally {
1353
1463
  if (options.dryRun !== true) this.engine.invalidate(ref);
1354
1464
  }
@@ -1381,7 +1491,11 @@ var CloudRoaring = class _CloudRoaring {
1381
1491
  */
1382
1492
  async setRetention(ref, policy) {
1383
1493
  validateSegmentRef(ref);
1384
- return setSegmentRetention(ref, { registry: this.requireRegistry("setRetention") }, policy);
1494
+ return setSegmentRetention(
1495
+ ref,
1496
+ { registry: this.requireRegistry("setRetention"), clock: this.clock, rng: this.rng },
1497
+ policy
1498
+ );
1385
1499
  }
1386
1500
  /**
1387
1501
  * **The stored retention policy**, or `null` if the segment has none (or has no registry row, or is a
@@ -1400,7 +1514,11 @@ var CloudRoaring = class _CloudRoaring {
1400
1514
  */
1401
1515
  async clearRetention(ref) {
1402
1516
  validateSegmentRef(ref);
1403
- return clearSegmentRetention(ref, { registry: this.requireRegistry("clearRetention") });
1517
+ return clearSegmentRetention(ref, {
1518
+ registry: this.requireRegistry("clearRetention"),
1519
+ clock: this.clock,
1520
+ rng: this.rng
1521
+ });
1404
1522
  }
1405
1523
  /**
1406
1524
  * **Run the retention sweep: retire every segment whose `expiresAt` has passed.** This is the call that acts on
@@ -1463,7 +1581,7 @@ var CloudRoaring = class _CloudRoaring {
1463
1581
  async retireExpired(options = {}) {
1464
1582
  const deps = this.lifecycleDeps("retireExpired");
1465
1583
  const result = await retireExpired(
1466
- { registry: deps.registry, storage: deps.storage },
1584
+ { registry: deps.registry, storage: deps.storage, clock: this.clock, rng: this.rng },
1467
1585
  { ...options, now: options.now ?? this.clock.now() }
1468
1586
  );
1469
1587
  if (options.dryRun !== true) {
@@ -1473,6 +1591,57 @@ var CloudRoaring = class _CloudRoaring {
1473
1591
  }
1474
1592
  return result;
1475
1593
  }
1594
+ /**
1595
+ * **Remove the registry rows that releases before 0.12.0 left as `deleted: true` envelopes.** Those releases never
1596
+ * removed a row they deleted, and no later call removes it either: it is invisible to every read, and every full
1597
+ * listing of the registry still reads it (one GET each). This is the one call that removes them. It is an admin call,
1598
+ * not a sweep, and nothing schedules it: run it once after the last process on a release before 0.12.0 is gone.
1599
+ *
1600
+ * ```ts
1601
+ * const preview = await store.reapRegistryTombstones({ dryRun: true });
1602
+ * console.log(`would remove ${preview.wouldReap} of ${preview.examined} rows read`);
1603
+ *
1604
+ * const done = await store.reapRegistryTombstones({ confirmNoLegacyWriters: true });
1605
+ * if (done.limited) runAgain(); // `limit` (default 1,000) stopped the run with rows unread
1606
+ * ```
1607
+ *
1608
+ * **What it removes.** Only a row that is `deleted: true` and whose token carries no incarnation id, which is what a
1609
+ * release before 0.12.0 left, or a 0.12.0 or later release left by deleting a row born before 0.12.0 (both are safe to
1610
+ * remove), whatever its `status`. It never touches a live row, a `destroyed` row that is not `deleted` (a crypto-shred's
1611
+ * or a `dropSegment`'s tombstone: the attestation of an erasure, which it cannot tell from another), or a deleted row
1612
+ * with an incarnation id, and it never touches a generation. **So it does not clean a bucket completely**: the
1613
+ * `destroyed` tombstones `dropSegment` leaves stay, so do live rows written before 0.12.0, and so does a tombstone a
1614
+ * 0.12.0 or later release wrote while `conditionalDelete` was off (GCS by default, S3 on a custom endpoint): every full
1615
+ * listing still reads it, and this call neither removes it nor runs on such a store.
1616
+ * It also covers the library's own bookkeeping rows when `namespace` is not set, by the same rule. The default
1617
+ * namespace cannot be scoped to: leave `namespace` out to cover it.
1618
+ *
1619
+ * **The two things it asks.** `confirmNoLegacyWriters: true` is required for a real run (without it, a
1620
+ * `ValidationError`, and nothing is requested): your statement that no process on a release before 0.12.0 writes
1621
+ * this registry, since one that did would, re-creating a removed name, issue the removed row's tokens again. And the
1622
+ * registry must apply a delete under a precondition: where its `conditionalDelete` capability is off (GCS by default,
1623
+ * S3 on a custom endpoint) it throws `CapabilityError` before any request, dry run included. A registry that keeps
1624
+ * no such rows (the in-memory and local-filesystem ones) throws `UnsupportedError`.
1625
+ *
1626
+ * **Each removal is fenced** on the version it read, so a `create` that lands over the envelope first wins, the
1627
+ * delete is refused, and the row is counted as `skipped.raced` (which also counts a row already gone). That holds where
1628
+ * the backend applies the precondition, which auto-detection checks (S3 only on an AWS host). On an endpoint that
1629
+ * ignores `If-Match` (MinIO, fake-gcs-server), a `conditionalDelete: true` you set makes this an unfenced delete: run
1630
+ * it with every writer stopped. On a versioned bucket a delete leaves a delete marker and the earlier version, record
1631
+ * included, so the name is not erased from the bucket. A `create` that had already read the envelope and
1632
+ * whose write meets the removal throws `WriteConflictError`, which this library does not retry: retry the create, and
1633
+ * it finds no row and writes one.
1634
+ *
1635
+ * **Cost** over R rows read and E removed: `ceil(R / 1000)` LIST requests, R GETs and E DELETEs (a dry run, no
1636
+ * DELETEs). `limit` bounds the removals, and the run stops listing once it is spent. **A run is not resumable**: each
1637
+ * lists and reads from the start (R GETs whatever the `limit`), a dry run with a `limit` reports the same first rows
1638
+ * every time, so `wouldReap` is a total only when `limited` is `false`, and `limited` is `true` whenever the limit was
1639
+ * spent with keys left, removable or not. An unreadable object stops the run with an error naming its key, and
1640
+ * removes nothing more: it may be a newer release's row. Scope the run with `namespace` to get past one.
1641
+ */
1642
+ async reapRegistryTombstones(options = {}) {
1643
+ return reapRegistryTombstones(this.requireRegistry("reapRegistryTombstones"), options);
1644
+ }
1476
1645
  /**
1477
1646
  * **Forget everything this store has cached about a segment.** Use it after destroying or retiring a segment
1478
1647
  * through a path this store did not perform itself.
@@ -1519,14 +1688,54 @@ var CloudRoaring = class _CloudRoaring {
1519
1688
  * pinned view reports the version captured at pin time, marked as a pin's, so its decoded chunks are never
1520
1689
  * those of a live read that fetched across a publish (see {@link PinnedStorageChunkSource.currentVersion}).
1521
1690
  */
1522
- async pinSegment(ref, expiresAt) {
1691
+ async pinSegment(ref, expiresAt, leaseUntil, named) {
1523
1692
  const crbm = this.crbmSource;
1524
1693
  if (crbm === void 0) {
1525
1694
  throw new UnsupportedError(
1526
1695
  "pin() needs the `.crbm` storage source \u2014 pass a backend or a raw IStorageDriver as `storage` (a pre-built StorageChunkSource that cannot resolve a generation has nothing to pin)"
1527
1696
  );
1528
1697
  }
1529
- const at = await this.withRetries(() => crbm.pinGeneration(ref));
1698
+ let hold;
1699
+ let take;
1700
+ if (leaseUntil !== void 0) {
1701
+ const registry = this.requireRegistry("pin({ leaseUntil })");
1702
+ if (registry.capabilities().canWrite === false) {
1703
+ throw new UnsupportedError(
1704
+ "pin({ leaseUntil }) needs a registry that can write a row, and this store's cannot in this runtime"
1705
+ );
1706
+ }
1707
+ const holder = this.mintHolder();
1708
+ const deps = { registry, clock: this.clock, rng: this.rng };
1709
+ const generationOf = (row) => named?.generation ?? row.currentGen;
1710
+ hold = {
1711
+ holder,
1712
+ until: leaseUntil,
1713
+ released: false,
1714
+ done: false,
1715
+ release: () => releaseLease(ref, deps, holder)
1716
+ };
1717
+ take = {
1718
+ take: (row) => takeLease(ref, deps, {
1719
+ holder,
1720
+ generation: generationOf(row),
1721
+ until: leaseUntil,
1722
+ row,
1723
+ current: named === void 0
1724
+ })
1725
+ };
1726
+ }
1727
+ let at;
1728
+ try {
1729
+ at = await this.withRetries(
1730
+ () => named === void 0 ? crbm.pinGeneration(ref, take) : crbm.pinGenerationAt(ref, named, take)
1731
+ );
1732
+ if (hold !== void 0 && at === null) {
1733
+ throw new NotFoundError(`segment "${ref.segment}" has no generation to lease`);
1734
+ }
1735
+ } catch (err) {
1736
+ if (hold !== void 0) await hold.release().catch(() => void 0);
1737
+ throw err;
1738
+ }
1530
1739
  const pinnedAt = {
1531
1740
  generation: at?.generation ?? null,
1532
1741
  version: at?.version ?? null,
@@ -1539,12 +1748,20 @@ var CloudRoaring = class _CloudRoaring {
1539
1748
  clock: this.clock,
1540
1749
  metrics: this.metrics,
1541
1750
  materialize: (dest, ids, op, options) => this.materialize(dest, ids, op, options),
1542
- pinned: (r, e) => this.pinSegment(r, e),
1751
+ pinned: (r, e, l, at2) => this.pinSegment(r, e, l, at2),
1543
1752
  combineEngine: (handles) => this.engineForCombine(handles),
1544
1753
  expiresAt,
1545
- pinnedAt
1754
+ pinnedAt,
1755
+ lease: hold
1546
1756
  });
1547
1757
  }
1758
+ /** 16 hex digits from the store's `Rng`, which names the holder of one lease: unique, not secret. */
1759
+ mintHolder() {
1760
+ let id = "";
1761
+ for (let i = 0; i < 4; i++)
1762
+ id += Math.floor(this.rng.next() * 65536).toString(16).padStart(4, "0");
1763
+ return id;
1764
+ }
1548
1765
  /**
1549
1766
  * An engine reading every segment in `pins` at its pinned generation and everything else live, with the store's
1550
1767
  * retries, so a transient fault on a pinned read is retried as it would be on a live one.
@@ -1702,6 +1919,34 @@ var EMPTY_IDS = {
1702
1919
  async *batches() {
1703
1920
  }
1704
1921
  };
1922
+ var AS_IDS = {
1923
+ none: EMPTY_IDS,
1924
+ failing,
1925
+ guard: guardIds,
1926
+ intersect: (engine, refs, opts) => withBatches(engine.intersect(refs, opts), () => engine.intersectBatches(refs, opts)),
1927
+ union: (engine, refs, opts) => withBatches(engine.union(refs, opts), () => engine.unionBatches(refs, opts)),
1928
+ andNot: (engine, base, refs, opts) => withBatches(engine.andNot(base, refs, opts), () => engine.andNotBatches(base, refs, opts))
1929
+ };
1930
+ var NO_CHUNKS = {
1931
+ async *[Symbol.asyncIterator]() {
1932
+ }
1933
+ };
1934
+ var CombineChunks = class {
1935
+ constructor(chunks) {
1936
+ this.chunks = chunks;
1937
+ }
1938
+ chunks;
1939
+ };
1940
+ var AS_CHUNKS = {
1941
+ none: NO_CHUNKS,
1942
+ guard: guardChunks,
1943
+ failing: (err) => ({
1944
+ [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(err) })
1945
+ }),
1946
+ intersect: (engine, refs, opts) => engine.intersectChunks(refs, opts),
1947
+ union: (engine, refs, opts) => engine.unionChunks(refs, opts),
1948
+ andNot: (engine, base, refs, opts) => engine.andNotChunks(base, refs, opts)
1949
+ };
1705
1950
  var KEEP_EVERY_GENERATION = Number.MAX_SAFE_INTEGER;
1706
1951
  var makeSegment;
1707
1952
  var minting = false;
@@ -1721,6 +1966,13 @@ var Segment = class _Segment {
1721
1966
  * pinned handle passed as an **operand** is still read at its pin rather than live.
1722
1967
  */
1723
1968
  pinnedAt;
1969
+ /**
1970
+ * The lease this handle holds, when it came from `pin({ leaseUntil })`; `undefined` otherwise. It reports what was
1971
+ * taken and does not change when the lease ends: a read after `lease.until` (or after {@link Segment.release}) throws
1972
+ * {@link LeaseExpiredError}.
1973
+ */
1974
+ lease;
1975
+ leaseHold;
1724
1976
  static {
1725
1977
  makeSegment = (parts) => {
1726
1978
  minting = true;
@@ -1750,6 +2002,10 @@ var Segment = class _Segment {
1750
2002
  this.combineEngine = parts.combineEngine;
1751
2003
  this.expiresAt = parts.expiresAt;
1752
2004
  this.pinnedAt = parts.pinnedAt;
2005
+ this.leaseHold = parts.lease;
2006
+ if (parts.lease !== void 0) {
2007
+ this.lease = Object.freeze({ holder: parts.lease.holder, until: parts.lease.until });
2008
+ }
1753
2009
  this.metricsOn = parts.metrics !== NOOP_METRICS;
1754
2010
  }
1755
2011
  /**
@@ -1778,11 +2034,20 @@ var Segment = class _Segment {
1778
2034
  * 0, so a pin of the old segment never reads the new one: what it has already read still answers, as the
1779
2035
  * instant it pinned, and anything it would have to fetch fails with `NotFoundError`, as a swept pin's does.
1780
2036
  *
1781
- * **It is a hold, not a lease.** Nothing here stops a collection deleting the generation underneath
1782
- * you: a pinned read deliberately does **not** heal forward, because silently serving a different generation
1783
- * is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover your longest pinned
1784
- * job — see [Generations and `keep`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/loading.md#generations-and-keep) — or take the pin on a segment
1785
- * you are not collecting.
2037
+ * **It is a hold, and `pin({ leaseUntil })` makes it a bounded one.** Without a lease nothing stops a collection
2038
+ * deleting the generation underneath you: a pinned read deliberately does **not** heal forward, because silently
2039
+ * serving a different generation is the one thing a pin exists to prevent, so it fails instead. Size `keep` to cover
2040
+ * your longest pinned job — see [Generations and `keep`](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/loading.md#generations-and-keep) — take the pin on a segment
2041
+ * you are not collecting, or lease it.
2042
+ *
2043
+ * **A lease** (`leaseUntil`: epoch **milliseconds**, after now and at most 14 days out) keeps the generation out of
2044
+ * a load's collection until then, from any process, and the pin is taken with it: the lease is written to the segment's
2045
+ * row before the object is opened. A read of the handle after the lease, or after {@link Segment.release}, throws
2046
+ * {@link LeaseExpiredError} at every read site, including when the handle is an operand or an `exclude` of a combine
2047
+ * or the target of an `*Into`, and never reads empty. A stream checks the lease each time it reads a chunk. A segment
2048
+ * holds at most 64 live leases ({@link LeaseLimitError}). A collector holds a lease 60 seconds past its end, which covers
2049
+ * clocks that differ by that much. Erasure, shred, drop and retention expiry ignore a lease. See
2050
+ * [Hold a generation for a job](https://github.com/cloudbitmaps/cloudbitmaps/blob/main/docs/guide/reading.md#hold-a-generation-for-a-job-a-lease).
1786
2051
  *
1787
2052
  * A segment with no current generation pins nothing and reads empty, exactly as it would unpinned. A pinned
1788
2053
  * segment whose row is later dropped or destroyed fails once it must open its object again, rather than go empty
@@ -1822,8 +2087,144 @@ var Segment = class _Segment {
1822
2087
  * Needs a store built on the `.crbm` storage source (the default when you pass a backend or a raw driver). Throws
1823
2088
  * {@link UnsupportedError} on a store wired with a pre-built source that cannot pin.
1824
2089
  */
1825
- async pin() {
1826
- return this.pinned(this.ref, this.expiresAt);
2090
+ async pin(options) {
2091
+ this.assertLeases([this]);
2092
+ const leaseUntil = this.leaseUntilOf("pin", options);
2093
+ return this.pinned(this.ref, this.expiresAt, leaseUntil);
2094
+ }
2095
+ /**
2096
+ * Hold what this pin names until `leaseUntil`, or `undefined` for a pin with no lease. Refuses an option it does not
2097
+ * know, a value that is not epoch-milliseconds in the future, one past the longest lease, and one past the handle's own
2098
+ * `expiresAt`, before any request is made.
2099
+ */
2100
+ leaseUntilOf(op, options) {
2101
+ if (options === void 0 || options === null) return void 0;
2102
+ if (typeof options !== "object" || Array.isArray(options)) {
2103
+ throw new ValidationError2(`${op}: options must be an object such as { leaseUntil }`);
2104
+ }
2105
+ const unknown = Object.keys(options).filter((k) => k !== "leaseUntil");
2106
+ if (unknown.length > 0) {
2107
+ throw new ValidationError2(
2108
+ `${op}: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; this version takes { leaseUntil } only`
2109
+ );
2110
+ }
2111
+ const until = options.leaseUntil;
2112
+ if (until === void 0) return void 0;
2113
+ const now = this.clock.now();
2114
+ if (typeof until !== "number" || !Number.isSafeInteger(until)) {
2115
+ throw new ValidationError2(
2116
+ `${op}: leaseUntil must be an integer epoch-MILLISECONDS; got ${String(until)}`
2117
+ );
2118
+ }
2119
+ if (until <= now) {
2120
+ throw new ValidationError2(
2121
+ `${op}: leaseUntil (${until}) is not after now (${now}); it is epoch MILLISECONDS, so a value in seconds reads as long past. Use \`Date.now() + hours * 3_600_000\``
2122
+ );
2123
+ }
2124
+ if (until > now + MAX_LEASE_MS) {
2125
+ throw new ValidationError2(
2126
+ `${op}: leaseUntil is more than 14 days away; the longest lease is ${MAX_LEASE_MS} ms. Take a new lease when a job outlasts it`
2127
+ );
2128
+ }
2129
+ if (this.expiresAt !== void 0 && until > this.expiresAt) {
2130
+ throw new ValidationError2(
2131
+ `${op}: leaseUntil outlasts this handle's own expiresAt, which would have it read empty while it still holds the generation`
2132
+ );
2133
+ }
2134
+ return until;
2135
+ }
2136
+ /**
2137
+ * End this handle's lease now, so a load's collection may take its generation. Idempotent: a handle with no lease, or
2138
+ * one already released, does nothing and makes no request. Every read of the handle after it throws
2139
+ * {@link LeaseExpiredError} (reason `'released'`), including one already under way at its next chunk. A lease that has
2140
+ * ended by its own time needs no release: this then makes no request either, and the registry row's entry is pruned
2141
+ * by a later write. Otherwise it is one registry read and one write; a failure of either is thrown as the registry's own
2142
+ * typed error, and the entry then holds until its time ends, `release()` being safe to call again.
2143
+ */
2144
+ async release() {
2145
+ const hold = this.leaseHold;
2146
+ if (hold === void 0) return;
2147
+ hold.released = true;
2148
+ if (hold.done) return;
2149
+ if (this.clock.now() >= hold.until + LEASE_SKEW_MS) {
2150
+ hold.done = true;
2151
+ return;
2152
+ }
2153
+ await hold.release();
2154
+ hold.done = true;
2155
+ }
2156
+ /**
2157
+ * The error a read of this handle must throw, or `undefined` when it holds no lease or the lease is live. One property
2158
+ * read on a handle with no lease. It is not {@link Segment.expired}: that rule reads empty, and a lease must never.
2159
+ */
2160
+ leaseError() {
2161
+ const hold = this.leaseHold;
2162
+ if (hold === void 0) return void 0;
2163
+ const name = this.ref.namespace ? `${this.ref.namespace}/${this.ref.segment}` : this.ref.segment;
2164
+ if (hold.released) {
2165
+ return new LeaseExpiredError(
2166
+ `the lease on segment "${name}" was released`,
2167
+ hold.until,
2168
+ "released"
2169
+ );
2170
+ }
2171
+ if (this.clock.now() >= hold.until) {
2172
+ return new LeaseExpiredError(
2173
+ `the lease on segment "${name}" ended; a read of a handle past its lease throws, never reads empty`,
2174
+ hold.until,
2175
+ "expired"
2176
+ );
2177
+ }
2178
+ return void 0;
2179
+ }
2180
+ /**
2181
+ * Throw the first lease error among `handles`: this handle and every operand and exclude of a call. It runs before
2182
+ * any `expiresAt` rule and before the engine, so no operand, exclude or cached reader can answer past a lease.
2183
+ */
2184
+ assertLeases(handles) {
2185
+ for (const h of handles) {
2186
+ const err = h.leaseError();
2187
+ if (err !== void 0) throw err;
2188
+ }
2189
+ }
2190
+ /**
2191
+ * Reopen a generation a pin named earlier, as a pinned handle like the one {@link Segment.pin} returns, held at
2192
+ * `at.generation` instead of the current one. Pass what the earlier pin recorded, `{ generation, fingerprint }`
2193
+ * from its {@link Segment.pinnedAt}. A generation number alone is not an identity, since a purged and re-created
2194
+ * name starts again at 0, so the fingerprint is required: a bare number, a malformed fingerprint or a key other
2195
+ * than `generation` and `fingerprint` is a {@link ValidationError}. Identify a pin by its `generation` and
2196
+ * `fingerprint`: the handle's `pinnedAt.version` can differ from the earlier pin's.
2197
+ *
2198
+ * Throws {@link NotFoundError} when the generation is gone (collected, purged, or on a crypto-shredded segment),
2199
+ * is above the row's pointer, or is another object than the fingerprint names, and never reads empty. A rollback
2200
+ * deletes nothing, so once a later load moves the pointer past a generation it rolled back from, that one can be
2201
+ * reopened while its object is stored. Nothing keeps a generation alive unless you lease it: a later load's
2202
+ * collection can delete it, which is sized by `keep` on every writer, or held by `options.leaseUntil` (see
2203
+ * {@link PinOptions} and {@link Segment.pin}). A leased `pinAt` writes the lease before it verifies the object, and
2204
+ * releases it if the verify fails; for a generation below the pointer, a collector that read the row before the lease
2205
+ * landed can still delete the generation inside its own round trip, and the handle then fails with `NotFoundError`
2206
+ * like any swept pin. A leased handle is checked at every read site as a leased `pin()` is. With a registry it costs one row read and one tail read,
2207
+ * and a leased `pinAt` one conditional write to the row besides; the handle
2208
+ * then reads as one from `pin()` does, including its failure on a chunk it has not cached once the generation is
2209
+ * swept. Works for an encrypted segment. The first argument is an object so that fields can join it later; the lease
2210
+ * is in `options`, the second.
2211
+ */
2212
+ async pinAt(at, options) {
2213
+ this.assertLeases([this]);
2214
+ const { generation, fingerprint } = at ?? {};
2215
+ const unknown = typeof at === "object" && at !== null ? Object.keys(at).filter((k) => k !== "generation" && k !== "fingerprint") : [];
2216
+ if (unknown.length > 0) {
2217
+ throw new ValidationError2(
2218
+ `pinAt: unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}; this version takes { generation, fingerprint } only`
2219
+ );
2220
+ }
2221
+ if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 0 || typeof fingerprint !== "string" || !/^\d+:\d+$/.test(fingerprint)) {
2222
+ throw new ValidationError2(
2223
+ "pinAt: pass { generation, fingerprint } as recorded by an earlier pin (its pinnedAt); a generation number without the fingerprint of its object does not identify it"
2224
+ );
2225
+ }
2226
+ const leaseUntil = this.leaseUntilOf("pinAt", options);
2227
+ return this.pinned(this.ref, this.expiresAt, leaseUntil, { generation, fingerprint });
1827
2228
  }
1828
2229
  /**
1829
2230
  * An opaque string that names this handle's segment, namespace included: two handles of one segment have the
@@ -1867,6 +2268,7 @@ var Segment = class _Segment {
1867
2268
  * awaiting.
1868
2269
  */
1869
2270
  refuseIfExpired(op, dest, operands) {
2271
+ this.assertLeases([this, dest, ...operands]);
1870
2272
  const stale = [];
1871
2273
  for (const seg of [this, dest, ...operands]) {
1872
2274
  if (seg.expired())
@@ -1878,6 +2280,10 @@ var Segment = class _Segment {
1878
2280
  );
1879
2281
  }
1880
2282
  }
2283
+ /** `result` as is, or, when a handle of the call holds a lease, checking that lease each time it reads a chunk. */
2284
+ guarded(out, handles, result) {
2285
+ return handles.some((h) => h.leaseHold !== void 0) ? out.guard(result, () => this.assertLeases(handles)) : result;
2286
+ }
1881
2287
  /**
1882
2288
  * Time an op with the injected clock and emit an `op` metric on completion (success or throw). Skipped
1883
2289
  * entirely when no sink is wired, so the default path pays nothing.
@@ -1893,6 +2299,8 @@ var Segment = class _Segment {
1893
2299
  }
1894
2300
  /** Membership: one chunk — the cache, else one ranged GET. Throws {@link ValidationError} on a bad id. */
1895
2301
  has(id) {
2302
+ const lease = this.leaseError();
2303
+ if (lease !== void 0) return Promise.reject(lease);
1896
2304
  if (this.expired()) return Promise.resolve(false);
1897
2305
  return this.timed("has", () => this.engine.has(this.ref, id));
1898
2306
  }
@@ -1915,6 +2323,8 @@ var Segment = class _Segment {
1915
2323
  * structure is checked.
1916
2324
  */
1917
2325
  count() {
2326
+ const lease = this.leaseError();
2327
+ if (lease !== void 0) return Promise.reject(lease);
1918
2328
  if (this.expired()) return Promise.resolve(0);
1919
2329
  return this.timed("count", () => this.engine.count(this.ref));
1920
2330
  }
@@ -1933,6 +2343,7 @@ var Segment = class _Segment {
1933
2343
  * ```
1934
2344
  */
1935
2345
  async stat() {
2346
+ this.assertLeases([this]);
1936
2347
  if (this.expired()) return { generation: null, cardinality: 0 };
1937
2348
  return this.engine.stat(this.ref);
1938
2349
  }
@@ -1952,16 +2363,70 @@ var Segment = class _Segment {
1952
2363
  * without checking its options, as every read of one does.
1953
2364
  */
1954
2365
  iterate(options) {
2366
+ const lease = this.leaseError();
2367
+ if (lease !== void 0) return failing(lease);
1955
2368
  if (this.expired()) return EMPTY_IDS;
1956
2369
  const range = options == null ? void 0 : rangeOf(options);
1957
2370
  const none = range === void 0 || range.after === void 0 && range.through === void 0;
1958
2371
  const { engine, ref } = this;
1959
2372
  const bounds = none ? void 0 : range;
1960
- return withBatches(engine.iterate(ref, bounds), () => engine.iterateBatches(ref, bounds));
2373
+ const stream = withBatches(
2374
+ engine.iterate(ref, bounds),
2375
+ () => engine.iterateBatches(ref, bounds)
2376
+ );
2377
+ return this.leaseHold === void 0 ? stream : guardIds(stream, () => this.assertLeases([this]));
2378
+ }
2379
+ /**
2380
+ * **Pinned handles only.** The ids at 1-based ranks `n`, `2n`, `3n` …, counted over the ids in `(after, through]`
2381
+ * when a range is given, ascending: `everyNth(1000)` yields the 1,000th id, then the 2,000th, and so on. A last
2382
+ * partial window yields nothing, so a caller cutting a send into windows of `n` appends its own final end.
2383
+ *
2384
+ * It places each boundary from the per-chunk counts the pin already holds and reads only the chunks that hold one,
2385
+ * each once however many boundaries it holds, so it reads at most one chunk per boundary and never more chunks
2386
+ * than the object has. A range with `after` inside a chunk reads that chunk too, to count the ids at or below
2387
+ * `after`, even when fewer than `n` ids remain above it; when the chunks in range hold fewer than `n` ids in all,
2388
+ * nothing is fetched. `through` stops the read and no chunk past it is read, though a `through` inside a chunk may read that last chunk and
2389
+ * yield nothing from it. The reads and the read-ahead window are
2390
+ * those of {@link Segment.iterate}, and the per-op budget is charged before the first fetch, with an upper bound on
2391
+ * the chunks the read can take. A pinned read after the generation is swept throws {@link NotFoundError}.
2392
+ *
2393
+ * The ranks come from the index's counts, which are trusted as {@link Segment.count} trusts them. A chunk that is
2394
+ * read must hold the number of ids its index says, or the read throws {@link IntegrityError}; a chunk that is not
2395
+ * read is not checked, so an index that is wrong about one, and, on an unencrypted object, still adds up to the footer's
2396
+ * total, moves the ids after it to the wrong ranks. An encrypted object's index is authenticated and bound to the
2397
+ * object, so a wrong count there needs the key.
2398
+ *
2399
+ * A live handle is refused with {@link UnsupportedError} at the first read, because its counts and its chunks could
2400
+ * come from two generations and name the wrong id. `n` that is not a positive integer throws
2401
+ * {@link ValidationError} at the first read, as does a bad bound. An expired handle reads empty.
2402
+ *
2403
+ * ```ts
2404
+ * const audience = await store.segment('audience').pin();
2405
+ * const ends: number[] = [];
2406
+ * for await (const id of audience.everyNth(1_000)) ends.push(id); // the end of each window of 1,000
2407
+ * ```
2408
+ */
2409
+ everyNth(n, options) {
2410
+ const lease = this.leaseError();
2411
+ if (lease !== void 0) return failing(lease);
2412
+ if (this.expired()) return EMPTY_IDS;
2413
+ if (this.pinnedAt === void 0) {
2414
+ return failing(
2415
+ new UnsupportedError(
2416
+ "everyNth needs a pinned handle: a live handle can place a rank by one generation and read it from another. Call `pin()` first."
2417
+ )
2418
+ );
2419
+ }
2420
+ const stream = this.engine.everyNth(
2421
+ this.ref,
2422
+ n,
2423
+ options == null ? void 0 : rangeOf(options)
2424
+ );
2425
+ return this.leaseHold === void 0 ? stream : guardIdIterable(stream, () => this.assertLeases([this]));
1961
2426
  }
1962
2427
  /**
1963
2428
  * The options a combine hands the engine, read once, when it is called ({@link readOptions}), with the `exclude`
1964
- * it was given, already reduced to its live handles ({@link liveExcludes}) and mapped down to the plain refs
2429
+ * it was given ({@link excludesOf}) and mapped down to the plain refs
1965
2430
  * `core` takes. A method rather than a module function because `ref` is class-private — the encapsulation is
1966
2431
  * worth more than the free function.
1967
2432
  *
@@ -1977,18 +2442,35 @@ var Segment = class _Segment {
1977
2442
  };
1978
2443
  }
1979
2444
  /**
1980
- * The handles in `options.exclude` that have not expired. **An expired exclusion excludes nothing**, in every
1981
- * combine, so it is dropped here, before the engine is asked for anything: it is never fetched, never
1982
- * checked for absence, and never counted in the pin-consistency check. `exclude` is read once, so a getter sees
1983
- * one call.
1984
- *
1985
- * With no `exclude` this is one property read and a shared empty list, and with none expired it returns the
1986
- * caller's own array, so the common case allocates nothing.
2445
+ * The handles in `options.exclude`, read once so a getter sees one call. With none this is one property read and
2446
+ * a shared empty list; otherwise it is the caller's own array, so the common case allocates nothing.
1987
2447
  */
1988
- liveExcludes(options) {
2448
+ excludesOf(options) {
1989
2449
  const exclude = options?.exclude;
1990
- if (exclude == null || exclude.length === 0) return NO_SEGMENTS;
1991
- return exclude.some((e) => e.expired()) ? exclude.filter((e) => !e.expired()) : exclude;
2450
+ return exclude == null || exclude.length === 0 ? NO_SEGMENTS : exclude;
2451
+ }
2452
+ /**
2453
+ * The refusal for a combine whose **exclusion** has expired, or `undefined` when none has. Unlike an expired
2454
+ * operand, which reads empty, an exclusion is a suppression or opt-out list: left out silently, the result
2455
+ * would include the very ids it was passed to remove. So it is refused with a {@link ValidationError} naming each
2456
+ * expired exclusion, as the `*Into` verbs refuse an expired handle.
2457
+ *
2458
+ * It is judged against the injected clock when the combine is called, before the engine is asked for anything:
2459
+ * no request is made, and an expired exclusion that names a segment that does not exist is refused as expired,
2460
+ * not as an absent operand. It runs ahead of the expired-operand shortcuts, so a combine that would read empty
2461
+ * anyway still refuses a lapsed exclusion. A stream already being read is not re-checked when an exclusion
2462
+ * expires part-way, as an operand expiring part-way does not stop it either.
2463
+ */
2464
+ expiredExcludes(op, excludes) {
2465
+ let stale;
2466
+ for (const e of excludes) {
2467
+ if (!e.expired()) continue;
2468
+ (stale ??= []).push(e.ref.namespace ? `${e.ref.namespace}/${e.ref.segment}` : e.ref.segment);
2469
+ }
2470
+ if (stale === void 0) return void 0;
2471
+ return new ValidationError2(
2472
+ `${op}: refusing to read while these exclusions have expired \u2014 ${[...new Set(stale)].join(", ")}. An expired exclusion would subtract nothing, so the result would include the ids it was passed to remove. Renew the exclusion's \`expiresAt\`, open it without one, or leave it out of the call.`
2473
+ );
1992
2474
  }
1993
2475
  /**
1994
2476
  * Chunk-skipping intersection: stream the ids in **this** segment AND every segment in `others`, ascending.
@@ -2000,17 +2482,28 @@ var Segment = class _Segment {
2000
2482
  * override the store's per-op denial-of-wallet budget for this call (or `false` to lift it).
2001
2483
  */
2002
2484
  intersect(others, options) {
2003
- if (this.expired() || others.some((o) => o.expired())) return EMPTY_IDS;
2004
- const exclude = this.liveExcludes(options);
2485
+ return this.intersectAs(AS_IDS, others, options);
2486
+ }
2487
+ intersectAs(out, others, options) {
2488
+ const exclude = this.excludesOf(options);
2489
+ const handles = [this, ...others, ...exclude];
2490
+ try {
2491
+ this.assertLeases(handles);
2492
+ } catch (err) {
2493
+ return out.failing(err);
2494
+ }
2495
+ const refused = this.expiredExcludes("intersect", exclude);
2496
+ if (refused) return out.failing(refused);
2497
+ if (this.expired() || others.some((o) => o.expired())) return out.none;
2005
2498
  let engine;
2006
2499
  try {
2007
2500
  engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
2008
2501
  } catch (err) {
2009
- return failing(err);
2502
+ return out.failing(err);
2010
2503
  }
2011
2504
  const refs = [this.ref, ...others.map((o) => o.ref)];
2012
2505
  const opts = this.refsIn(options, exclude);
2013
- return withBatches(engine.intersect(refs, opts), () => engine.intersectBatches(refs, opts));
2506
+ return this.guarded(out, handles, out.intersect(engine, refs, opts));
2014
2507
  }
2015
2508
  /**
2016
2509
  * Materialize `this ∩ others…` (minus `exclude`) as a **new generation of `dest`** — `dest`'s previous contents
@@ -2037,7 +2530,12 @@ var Segment = class _Segment {
2037
2530
  this.combineEngine([this, ...others, ...options?.exclude ?? []]);
2038
2531
  return this.timed(
2039
2532
  "intersectInto",
2040
- () => this.materialize(dest.ref, this.intersect(others, options), "intersectInto", options)
2533
+ () => this.materialize(
2534
+ dest.ref,
2535
+ new CombineChunks(this.intersectAs(AS_CHUNKS, others, options)),
2536
+ "intersectInto",
2537
+ options
2538
+ )
2041
2539
  );
2042
2540
  }
2043
2541
  /**
@@ -2051,26 +2549,36 @@ var Segment = class _Segment {
2051
2549
  * segment once (`unionInto`, or a load) is the cheaper shape.
2052
2550
  */
2053
2551
  union(others, options) {
2552
+ return this.unionAs(AS_IDS, others, options);
2553
+ }
2554
+ unionAs(out, others, options) {
2555
+ const exclude = this.excludesOf(options);
2556
+ const handles = [this, ...others, ...exclude];
2557
+ try {
2558
+ this.assertLeases(handles);
2559
+ } catch (err) {
2560
+ return out.failing(err);
2561
+ }
2562
+ const refused = this.expiredExcludes("union", exclude);
2563
+ if (refused) return out.failing(refused);
2054
2564
  const live = others.filter((o) => !o.expired());
2055
2565
  if (this.expired()) {
2056
- if (live.length === 0) return EMPTY_IDS;
2057
- return live[0].union(live.slice(1), options);
2566
+ if (live.length === 0) return out.none;
2567
+ return live[0].unionAs(out, live.slice(1), options);
2058
2568
  }
2059
2569
  if (live.length === 0 && others.length > 0) {
2060
- const exclude2 = this.liveExcludes(options);
2061
- return exclude2.length > 0 ? this.andNot([...exclude2], options) : this.union([], options);
2570
+ return exclude.length > 0 ? this.andNotAs(out, [...exclude], options) : this.unionAs(out, [], options);
2062
2571
  }
2063
- if (live.length !== others.length) return this.union(live, options);
2064
- const exclude = this.liveExcludes(options);
2572
+ if (live.length !== others.length) return this.unionAs(out, live, options);
2065
2573
  let engine;
2066
2574
  try {
2067
2575
  engine = this.combineEngine([this, ...others, ...exclude]) ?? this.engine;
2068
2576
  } catch (err) {
2069
- return failing(err);
2577
+ return out.failing(err);
2070
2578
  }
2071
2579
  const refs = [this.ref, ...others.map((o) => o.ref)];
2072
2580
  const opts = this.refsIn(options, exclude);
2073
- return withBatches(engine.union(refs, opts), () => engine.unionBatches(refs, opts));
2581
+ return this.guarded(out, handles, out.union(engine, refs, opts));
2074
2582
  }
2075
2583
  /** Materialize `this ∪ others…` (minus `exclude`) as a **new generation of `dest`** — see {@link intersectInto}. */
2076
2584
  async unionInto(dest, others, options) {
@@ -2078,7 +2586,12 @@ var Segment = class _Segment {
2078
2586
  this.combineEngine([this, ...others, ...options?.exclude ?? []]);
2079
2587
  return this.timed(
2080
2588
  "unionInto",
2081
- () => this.materialize(dest.ref, this.union(others, options), "unionInto", options)
2589
+ () => this.materialize(
2590
+ dest.ref,
2591
+ new CombineChunks(this.unionAs(AS_CHUNKS, others, options)),
2592
+ "unionInto",
2593
+ options
2594
+ )
2082
2595
  );
2083
2596
  }
2084
2597
  /**
@@ -2089,33 +2602,36 @@ var Segment = class _Segment {
2089
2602
  * suppression list: at most one read per surviving key of `this`, so subtracting a 61,000-chunk global
2090
2603
  * opt-out list from a 40-chunk audience costs at most 40 reads, not 61,000.
2091
2604
  *
2092
- * An expired handle in `excludes` excludes nothing, and is skipped without being read; if every one has
2093
- * expired the result is `this` whole. An expired `this` is empty. `exclude` on `intersect` and `union` follows
2094
- * the same rule.
2605
+ * An expired handle in `excludes` is refused, not skipped: the stream throws {@link ValidationError} naming it,
2606
+ * before any request is made, because a lapsed suppression list would otherwise let through the ids it was
2607
+ * passed to remove. An expired `this` is empty. `exclude` on `intersect` and `union` follows the same rule.
2095
2608
  *
2096
2609
  * To filter the *result of an intersection*, do not chain — pass `exclude` to {@link intersect} instead, so
2097
2610
  * the suppression folds into the same pass rather than materializing an intermediate segment first.
2098
2611
  */
2099
2612
  andNot(excludes, options) {
2100
- if (this.expired()) return EMPTY_IDS;
2101
- const liveExcludes = excludes.filter((e) => !e.expired());
2102
- if (liveExcludes.length === 0 && excludes.length > 0) {
2103
- return this.union([], options == null ? void 0 : readOptions(options));
2613
+ return this.andNotAs(AS_IDS, excludes, options);
2614
+ }
2615
+ andNotAs(out, excludes, options) {
2616
+ const handles = [this, ...excludes];
2617
+ try {
2618
+ this.assertLeases(handles);
2619
+ } catch (err) {
2620
+ return out.failing(err);
2104
2621
  }
2105
- if (liveExcludes.length !== excludes.length) return this.andNot(liveExcludes, options);
2622
+ const refused = this.expiredExcludes("andNot", excludes);
2623
+ if (refused) return out.failing(refused);
2624
+ if (this.expired()) return out.none;
2106
2625
  let engine;
2107
2626
  try {
2108
2627
  engine = this.combineEngine([this, ...excludes]) ?? this.engine;
2109
2628
  } catch (err) {
2110
- return failing(err);
2629
+ return out.failing(err);
2111
2630
  }
2112
2631
  const base = this.ref;
2113
2632
  const refs = excludes.map((o) => o.ref);
2114
2633
  const opts = options == null ? void 0 : readOptions(options);
2115
- return withBatches(
2116
- engine.andNot(base, refs, opts),
2117
- () => engine.andNotBatches(base, refs, opts)
2118
- );
2634
+ return this.guarded(out, handles, out.andNot(engine, base, refs, opts));
2119
2635
  }
2120
2636
  /** Materialize `this \ (excludes…)` as a **new generation of `dest`** — see {@link intersectInto}. */
2121
2637
  async andNotInto(dest, excludes, options) {
@@ -2123,7 +2639,12 @@ var Segment = class _Segment {
2123
2639
  this.combineEngine([this, ...excludes]);
2124
2640
  return this.timed(
2125
2641
  "andNotInto",
2126
- () => this.materialize(dest.ref, this.andNot(excludes, options), "andNotInto", options)
2642
+ () => this.materialize(
2643
+ dest.ref,
2644
+ new CombineChunks(this.andNotAs(AS_CHUNKS, excludes, options)),
2645
+ "andNotInto",
2646
+ options
2647
+ )
2127
2648
  );
2128
2649
  }
2129
2650
  /**
@@ -2134,6 +2655,7 @@ var Segment = class _Segment {
2134
2655
  * the lose-zone).
2135
2656
  */
2136
2657
  async costReport(options) {
2658
+ this.assertLeases([this]);
2137
2659
  const canMeasure = this.engine.supportsStorageSize;
2138
2660
  const size = canMeasure ? await this.engine.segmentSize(this.ref) : null;
2139
2661
  const refreshMs = this.engine.pointerRefreshMs;