Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion docs/bench-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ properties follow from that framing rather than having to be built:
A failing bench is held off with exponential backoff (15s, doubling to a
10-minute ceiling) so a sidecar outage is not hammered, and a bench is
never dropped for failing — the row stays until it converges or its TTL
(24 hours) expires.
(24 hours) expires. That backoff bookkeeping is reclaimed once the row
itself is gone — TTL-expiry or otherwise — not only when the bench
converges, so a permanently-failing bench never leaves a stale backoff
behind for a later, unrelated connect to the same user/tenant to
inherit (CL-7233).

## Sessions

Expand Down
176 changes: 176 additions & 0 deletions packages/collections/LICENSE

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions packages/collections/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# @corbits/collections

Bounded, self-evicting in-memory collections for state that would
otherwise grow for the lifetime of the process that holds it (CL-7233).

## `createExpiringMap`

A `Map`-like store where every entry carries a TTL from the moment it is
set. `get` drops an expired entry lazily on read; `set` opportunistically
sweeps every expired entry once per TTL window, so memory tracks recent
activity rather than the lifetime count of distinct keys ever seen —
without a timer to leak or `unref`.

```ts
import { createExpiringMap } from "@corbits/collections";

const lastSeenByUser = createExpiringMap<string, number>({ ttlMs: 10_000 });
lastSeenByUser.set(userId, Date.now());
lastSeenByUser.get(userId); // undefined once ttlMs has elapsed
```

This is the first primitive in the package. `apps/hub/src/launch-caches.ts`'s
`BoundedCache` (size-capped LRU, no TTL) is a sibling that predates this
package — CL-7229 and CL-7223 are expected to either consume
`createExpiringMap` directly or contribute the size-capped-LRU shape here
so `BoundedCache` can retire in favor of one place for this problem.
19 changes: 19 additions & 0 deletions packages/collections/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@corbits/collections",
"private": true,
"description": "Bounded, self-evicting in-memory collections for process-lifetime state (CL-7233) — a TTL map today, with orchestrator/crypto-cache callers (CL-7229, CL-7223) expected to consolidate onto it rather than growing their own",
"version": "0.0.1",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "catalog:",
"typescript": "catalog:"
}
}
151 changes: 151 additions & 0 deletions packages/collections/src/expiring-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { describe, expect, test } from "bun:test";
import { createExpiringMap } from "./expiring-map";

/** A controllable clock: advances only when the test tells it to, so
* sweep and expiry timing is asserted exactly rather than raced against
* a real timer. */
function fakeClock(startAt = 0) {
let now = startAt;
return {
now: () => now,
advance(ms: number) {
now += ms;
},
};
}

describe("createExpiringMap", () => {
test("returns a value before its ttl elapses", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
clock.advance(999);

expect(map.get("a")).toBe(1);
});

test("drops a value once its ttl elapses, without a further sweep or set", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
clock.advance(1_000);

expect(map.get("a")).toBeUndefined();
});

test("size reflects the lazy expiry a get() just performed", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
clock.advance(1_000);
expect(map.get("a")).toBeUndefined();

expect(map.size).toBe(0);
});

test("a fresh set on an existing key resets its ttl", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
clock.advance(600);
map.set("a", 2);
clock.advance(600);

// 1200ms since the first set, but only 600ms since the refresh.
expect(map.get("a")).toBe(2);
});

test("size never counts an expired entry, even if nothing has read or swept it yet", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
clock.advance(5_000);

// Neither get() nor a further set() has touched "a" since it
// expired — size on its own must still report it as gone.
expect(map.size).toBe(0);
});

test("delete removes a key outright", () => {
const map = createExpiringMap<string, number>({ ttlMs: 1_000 });
map.set("a", 1);

expect(map.delete("a")).toBe(true);
expect(map.get("a")).toBeUndefined();
expect(map.delete("a")).toBe(false);
});

test("a set-triggered sweep clears every expired key, not just the one being set", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
map.set("b", 2);
clock.advance(1_000);
// Neither "a" nor "b" has been read since expiring, so nothing has
// lazily dropped them yet — only the sweep this set triggers does.
map.set("c", 3);

expect(map.size).toBe(1);
expect(map.get("c")).toBe(3);
});

test("a set before the sweep interval elapses does not sweep other expired entries early", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

map.set("a", 1);
clock.advance(1_000);
map.set("b", 2);
// The sweep triggered by setting "b" cleared "a"; "b" itself is
// fresh and must survive a set that happens well within its own ttl.
clock.advance(1);
map.set("c", 3);

expect(map.get("b")).toBe(2);
expect(map.get("c")).toBe(3);
});

test("many distinct keys within one ttl window do not grow past the window's own traffic once it passes", () => {
const clock = fakeClock();
const map = createExpiringMap<string, number>({
ttlMs: 1_000,
now: clock.now,
});

for (let i = 0; i < 500; i += 1) map.set(`user_${i}`, i);
expect(map.size).toBe(500);

clock.advance(1_000);
// A single new key's set sweeps the whole prior window away.
map.set("user_new", 1);

expect(map.size).toBe(1);
});
});
66 changes: 66 additions & 0 deletions packages/collections/src/expiring-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// A TTL-eviction map for process-lifetime state whose only invariant is
// "how recently was this key touched" — a rate limiter, a dedupe guard,
// anything where an entry older than its own TTL is worthless and safe
// to forget (CL-7233). `get` drops an expired entry lazily on read;
// `set` opportunistically sweeps every expired entry once per TTL
// window, amortizing the cost of a full pass rather than checking every
// key on every call. There is no background timer: nothing to `unref`,
// nothing to leak if the map itself is dropped, and a test can drive it
// entirely with a fake clock.
export type ExpiringMap<K, V> = {
get(key: K): V | undefined;
set(key: K, value: V): void;
delete(key: K): boolean;
/** The count of live entries as of this read — always sweeps first,
* since a stale expired-but-unswept count would defeat the point of
* exposing size at all (a caller reading it as a memory/cardinality
* signal). */
readonly size: number;
};

type Entry<V> = { value: V; expiresAt: number };

export function createExpiringMap<K, V>(options: {
readonly ttlMs: number;
readonly now?: () => number;
}): ExpiringMap<K, V> {
const now = options.now ?? Date.now;
const entries = new Map<K, Entry<V>>();
let lastSweptAt = now();

function isExpired(entry: Entry<V>, at: number): boolean {
return at >= entry.expiresAt;
}

function sweep(at: number): void {
lastSweptAt = at;
for (const [key, entry] of entries) {
if (isExpired(entry, at)) entries.delete(key);
}
}

return {
get(key) {
const entry = entries.get(key);
if (entry === undefined) return undefined;
const at = now();
if (isExpired(entry, at)) {
entries.delete(key);
return undefined;
}
return entry.value;
},
set(key, value) {
const at = now();
if (at - lastSweptAt >= options.ttlMs) sweep(at);
entries.set(key, { value, expiresAt: at + options.ttlMs });
},
delete(key) {
return entries.delete(key);
},
get size() {
sweep(now());
return entries.size;
},
};
}
1 change: 1 addition & 0 deletions packages/collections/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createExpiringMap, type ExpiringMap } from "./expiring-map";
8 changes: 8 additions & 0 deletions packages/collections/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext"],
"types": ["bun"]
},
"include": ["src"]
}
1 change: 1 addition & 0 deletions packages/onboarding/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test": "bun test"
},
"dependencies": {
"@corbits/collections": "workspace:*",
"@corbits/error-sink": "workspace:*",
"@intx/crypto": "0.3.0",
"@intx/hub-api": "workspace:*",
Expand Down
Loading
Loading