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
6 changes: 6 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
reviews:
auto_review:
enabled: true
base_branches:
- main
- "grok/*"
10 changes: 9 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@
"cSpell.words": [
"PGDATABASE",
"PGPORT"
]
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"js/ts.experimental.useTsgo": true,
"typescript.native-preview.tsdk": "node_modules/@typescript/native-preview"
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ent-framework",
"description": "A PostgreSQL graph-database-alike library with microsharding and row-level security",
"version": "3.0.1",
"version": "3.0.2",
"license": "MIT",
"keywords": [
"postgresql",
Expand Down
32 changes: 25 additions & 7 deletions src/internal/CachedRefreshedValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Memoize } from "fast-typescript-memoize";
import type { DeferredPromise } from "p-defer";
import pDefer from "p-defer";
import type { MaybeCallable } from "./misc";
import { maybeCall, runInVoid } from "./misc";
import { maybeCall, maybeRunInSeparateTrace, runInVoid } from "./misc";

export interface CachedRefreshedValueOptions<TValue> {
/** Delay between calling resolver. */
Expand Down Expand Up @@ -111,6 +111,16 @@ export class CachedRefreshedValue<TValue> {

@Memoize()
private async refreshLoop(): Promise<void> {
// Whether the upcoming resolverFn() call must run in the caller's async
// context (and thus inherit its APM/tracing trace id), or in a fresh,
// detached trace. The very 1st refresh is triggered synchronously by the
// very 1st cached() caller (e.g. the 1st query), so it intentionally stays
// in that caller's trace. Same for refreshes triggered manually via
// refreshAndWait() (e.g. Cluster#rediscover()). But refreshes triggered on
// the timer (or by a deps change) must NOT pollute the trace of whoever
// happened to start the loop - each such refresh gets its own fresh trace.
let keepCallerTrace = true;
Comment on lines +114 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and print the relevant regions of the file with line numbers
FILE="src/internal/CachedRefreshedValue.ts"

echo "=== File: $FILE (around keepCallerTrace) ==="
nl -ba "$FILE" | sed -n '80,220p'

echo "=== Search for keepCallerTrace / skipDelay / refreshAndWait / maybeRunInSeparateTrace usage ==="
rg -n "keepCallerTrace|skipDelay|refreshAndWait|refreshLoop|depsTimeoutBody|maybeRunInSeparateTrace" "$FILE"

echo "=== Print surrounding definitions for skipDelay and refreshAndWait if found ==="
# Print a wider window around skipDelay and refreshAndWait occurrences
for pat in "skipDelay" "refreshAndWait" "refreshLoop" "depsTimeoutBody" "maybeRunInSeparateTrace"; do
  echo "--- Pattern: $pat ---"
  line=$(rg -n "$pat" "$FILE" | head -n 1 | cut -d: -f1 || true)
  if [[ -n "${line:-}" ]]; then
    start=$((line-40))
    end=$((line+80))
    nl -ba "$FILE" | sed -n "${start},${end}p"
  fi
done

Repository: dimikot/ent-framework

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/internal/CachedRefreshedValue.ts"

echo "=== Search for key identifiers ==="
rg -n "keepCallerTrace|skipDelay|refreshAndWait|refreshLoop|depsTimeoutBody|maybeRunInSeparateTrace" "$FILE"

echo "=== Numbered excerpt around the keepCallerTrace definition (fallback: 1-260) ==="
# Limit output to avoid huge dumps; adjust if file is smaller
wc -l "$FILE"
START=1
END=260
awk -v s="$START" -v e="$END" '{ if(NR>=s && NR<=e) printf "%6d\t%s\n", NR, $0 }' "$FILE"

Repository: dimikot/ent-framework

Length of output: 11768


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "function maybeRunInSeparateTrace|maybeRunInSeparateTrace" src/internal -S
ls -la src/internal

Repository: dimikot/ent-framework

Length of output: 1102


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "maybeRunInSeparateTrace" src/internal -S

Repository: dimikot/ent-framework

Length of output: 424


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/internal/misc.ts"
# Show around maybeRunInSeparateTrace
start=400
end=520
awk -v s="$start" -v e="$end" '{ if(NR>=s && NR<=e) printf "%6d\t%s\n", NR, $0 }' "$FILE"

Repository: dimikot/ent-framework

Length of output: 3932


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/internal/misc.ts"
rg -n "runInVoid" "$FILE"

Repository: dimikot/ent-framework

Length of output: 96


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/internal/misc.ts"
awk 'NR>=360 && NR<=460 { printf "%6d\t%s\n", NR, $0 }' "$FILE"

Repository: dimikot/ent-framework

Length of output: 4620


🏁 Script executed:

#!/bin/bash
set -euo pipefail
node <<'NODE'
const { AsyncLocalStorage } = require('async_hooks');

const als = new AsyncLocalStorage();

function defer() {
  let resolve;
  const promise = new Promise(r => { resolve = r; });
  return { promise, resolve };
}

async function test({label}) {
  const d = defer();

  let createdIn;
  let resolvedIn;

  await als.run({id: 'A'}, async () => {
    createdIn = als.getStore().id;
    // resolve promise later, from another ALS context
  });

  await new Promise(res => setImmediate(res)); // ensure A context unwound

  await als.run({id: 'B'}, async () => {
    resolvedIn = als.getStore().id;
    d.resolve();
  });

  const resumedId = await d.promise.then(() => als.getStore()?.id ?? null);
  return {label, createdIn, resolvedIn, resumedId};
}

(async () => {
  const out = [];
  out.push(await test({label: 'promise-resolve-in-B'}));
  console.log(JSON.stringify(out, null, 2));
})();
NODE

Repository: dimikot/ent-framework

Length of output: 181


🏁 Script executed:

#!/bin/bash
set -euo pipefail
node <<'NODE'
const { AsyncLocalStorage } = require('async_hooks');

const als = new AsyncLocalStorage();

function defer() {
  let resolve;
  const promise = new Promise(r => { resolve = r; });
  return { promise, resolve };
}

const d = defer();

async function awaitingInA() {
  let before, after;
  await als.run({id:'A'}, async () => {
    before = als.getStore().id;
    await d.promise; // continuation should run with A if ALS is propagated via promise creation
    after = als.getStore().id;
  });
  return { before, after };
}

(async () => {
  const waiter = awaitingInA();
  // let waiter start and reach await
  await new Promise(r => setImmediate(r));
  await als.run({id:'B'}, async () => {
    d.resolve();
  });
  const res = await waiter;
  console.log(JSON.stringify(res, null, 2));
})();
NODE

Repository: dimikot/ent-framework

Length of output: 101


Fix: refreshAndWait() manual refresh should run the next pass in the requesting caller’s trace, not the loop starter’s trace.

  • skipDelay only flips the keepCallerTrace boolean (lines 164-167). When keepCallerTrace becomes true, maybeRunInSeparateTrace(true) just runs inline (func()), so the next resolver pass still executes in refreshLoop()’s current AsyncLocalStorage context (the loop starter), not the caller that invoked refreshAndWait(). A regression should show two distinct trace IDs when two callers interact with the same in-flight loop (starting in caller A, triggering refreshAndWait() from caller B).
  • Similarly, deps-change polling (depsTimeoutBody at lines 169-183) calls this.options.deps.handler() without maybeRunInSeparateTrace, so timer-driven deps checks can also attach to the loop starter’s trace despite the comment’s “timer/deps change must NOT pollute” intent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/internal/CachedRefreshedValue.ts` around lines 114 - 122, The refresh
logic incorrectly uses the loop's current AsyncLocalStorage context for the
"next pass" when refreshAndWait() sets skipDelay/keepCallerTrace; fix by
capturing the caller's async context when refreshAndWait() is invoked and reuse
that context for the next resolver pass (modify refreshAndWait() to store the
caller context instead of just flipping keepCallerTrace, and adjust
refreshLoop()/maybeRunInSeparateTrace() to accept and run the next pass under
that captured context via AsyncLocalStorage.run or equivalent). Also ensure
depsTimeoutBody calls this.options.deps.handler() through
maybeRunInSeparateTrace configured to run in a fresh/detached trace (so
timer-driven deps checks never inherit the loop starter's trace).


while (!this.destroyedError) {
const warningDelayMs = maybeCall(this.options.warningTimeoutMs);
const depsDelayMs = maybeCall(this.options.deps.delayMs);
Expand All @@ -131,22 +141,30 @@ export class CachedRefreshedValue<TValue> {
let depsPrev: unknown = undefined;
try {
this.resolverFnCallCount++;
depsPrev = await this.options.deps.handler();
this.latestValue = await this.options.resolverFn();
await maybeRunInSeparateTrace(keepCallerTrace, async () => {
depsPrev = await this.options.deps.handler();
this.latestValue = await this.options.resolverFn();
});
const oldNextValue = this.nextValue;
this.nextValue = pDefer();
oldNextValue.resolve(this.latestValue);
oldNextValue.resolve(this.latestValue!);
} catch (e: unknown) {
this.onError(e, Math.round(performance.now() - startTime));
} finally {
clearTimeout(warningTimeout);
}

// Wait for delayMs. If this.skipDelay() is called, the code unfreezes
// immediately. Also, deps are rechecked every depsDelayMs, and if they
// change, the code unfreezes too.
// immediately, and the next refresh is treated as caller-triggered (so it
// keeps the trace). Also, deps are rechecked every depsDelayMs, and if
// they change, the code unfreezes too (and the next refresh is treated as
// timer-triggered, i.e. it runs in a fresh detached trace).
keepCallerTrace = false;
const delayDefer = pDefer<void>();
this.skipDelay = () => delayDefer.resolve();
this.skipDelay = () => {
keepCallerTrace = true;
delayDefer.resolve();
};

let depsTimeoutBody: null | (() => void) = () =>
runInVoid(async () => {
Expand Down
85 changes: 85 additions & 0 deletions src/internal/__tests__/CachedRefreshedValue.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "async_hooks";
import delay from "delay";
import pDefer from "p-defer";
import waitForExpect from "wait-for-expect";
Expand Down Expand Up @@ -329,3 +330,87 @@ test("changes in deps are respected", async () => {
depsValue = "other";
await waitForExpect(async () => expect(await cache.cached()).toEqual("two"));
});

test("timer-triggered refreshes each run in a separate trace", async () => {
// Models how an async_hooks based APM tracer derives a "trace id" from the
// current async context: if a query runs with an active trace, it joins it;
// otherwise (i.e. when the framework detached us into a fresh async context),
// the tracer opens a brand-new trace AND pins it to the current context via
// enterWith() (so it would "leak" into the next refresh and coalesce all
// refreshes into one trace, were the detachment not restoring a clean
// baseline on every iteration).
const als = new AsyncLocalStorage<{ traceId: string }>();
let detachedTraceSeq = 0;
const traces: string[] = [];
const recordTrace = (): void => {
let store = als.getStore();
if (!store) {
store = { traceId: `detached-trace-${++detachedTraceSeq}` };
als.enterWith(store);
}

traces.push(store.traceId);
};

cache = new CachedRefreshedValue({
...OPTIONS,
delayMs: 10,
resolverFn: async () => {
recordTrace();
return "value";
},
});

// The very 1st refresh is triggered synchronously by this cached() call (akin
// to the 1st query), so it must run in (and thus inherit) the caller's trace.
await als.run({ traceId: "caller-trace" }, async () => cache.cached());

// Let several timer-triggered refreshes happen.
await waitForExpect(() => expect(traces.length).toBeGreaterThanOrEqual(4));

expect(traces[0]).toBe("caller-trace");

// Every subsequent (timer-triggered) refresh ran detached from the caller's
// trace, and each one got its own brand-new trace.
const timerTraces = traces.slice(1);
expect(
timerTraces.every((trace) => trace.startsWith("detached-trace-")),
).toBe(true);
expect(new Set(timerTraces).size).toBe(timerTraces.length);
});

test("manual refreshAndWait() keeps the caller's trace", async () => {
const als = new AsyncLocalStorage<{ traceId: string }>();
let detachedTraceSeq = 0;
const traces: string[] = [];
const recordTrace = (): void => {
let store = als.getStore();
if (!store) {
store = { traceId: `detached-trace-${++detachedTraceSeq}` };
als.enterWith(store);
}

traces.push(store.traceId);
};

cache = new CachedRefreshedValue({
...OPTIONS,
// Large delay, so no timer-triggered refresh interferes: only the initial
// refresh and the manual refreshAndWait() ones happen.
delayMs: 1_000_000,
resolverFn: async () => {
recordTrace();
return "value";
},
});

await als.run({ traceId: "caller-trace" }, async () => {
await cache.cached(); // initial refresh
await cache.refreshAndWait(); // manual rediscovery
await cache.refreshAndWait(); // manual rediscovery
});

// Neither the initial nor the manual refreshes were detached into a fresh
// trace: they all kept running in the loop's (caller's) trace.
expect(traces).toEqual(["caller-trace", "caller-trace", "caller-trace"]);
});
38 changes: 38 additions & 0 deletions src/internal/misc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "async_hooks";
import { createHash } from "crypto";
import { inspect } from "util";
import compact from "lodash/compact";
Expand Down Expand Up @@ -399,6 +400,43 @@ export function runInVoid(
}
}

/**
* A snapshot of the "clean" async context, captured at module load time, i.e.
* BEFORE any request-scoped async context (such as an incoming HTTP request, and
* thus the APM/tracing "trace id" that tracers propagate through
* AsyncLocalStorage) could possibly have been established. Calling the returned
* runner executes a callback within that captured clean context.
*
* We deliberately use AsyncLocalStorage.snapshot() and NOT AsyncResource: both
* can detach an async context, but AsyncLocalStorage (including .snapshot()) is
* supported across runtimes (Node, Deno, Bun), whereas AsyncResource is a
* non-functional stub in some of them. If .snapshot() is unavailable (very old
* runtimes), we degrade gracefully to running the callback inline (no
* detachment).
*/
const runInRootAsyncContext: <T>(func: () => T) => T =
typeof AsyncLocalStorage?.snapshot === "function"
? AsyncLocalStorage.snapshot()
: (func) => func();
Comment on lines +403 to +420

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and print the relevant region around the snippet and call sites
ls -la
sed -n '360,520p' src/internal/misc.ts | nl -ba | sed -n '1,220p'

# Find all references to runInRootAsyncContext / maybeRunInSeparateTrace
rg -n "runInRootAsyncContext|maybeRunInSeparateTrace|AsyncLocalStorage\.snapshot" -S src/internal/misc.ts src/internal -g'*.ts'

Repository: dimikot/ent-framework

Length of output: 2381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant region with line numbers (cat -n)
sed -n '360,520p' src/internal/misc.ts | cat -n

# Search for the key symbols/usages across the repo (scoped to TS)
rg -n "runInRootAsyncContext|maybeRunInSeparateTrace|AsyncLocalStorage\.snapshot" -S src -g'*.ts'

Repository: dimikot/ent-framework

Length of output: 6187


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' src/internal/misc.ts | cat -n
sed -n '380,470p' src/internal/misc.ts | cat -n
sed -n '120,210p' src/internal/CachedRefreshedValue.ts | cat -n

Repository: dimikot/ent-framework

Length of output: 13354


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all usages of maybeRunInSeparateTrace and where misc.ts is imported
rg -n "maybeRunInSeparateTrace\\(" -S src -g'*.ts'
rg -n "import\\s*\\{\\s*maybeRunInSeparateTrace\\b" -S src -g'*.ts'

# Search for AsyncLocalStorage instantiation / usage across the repo
rg -n "new\\s+AsyncLocalStorage\\b|AsyncLocalStorage\\." -S src -g'*.ts'

Repository: dimikot/ent-framework

Length of output: 175


🌐 Web query:

Node.js AsyncLocalStorage snapshot is it static or instance method

💡 Result:

The AsyncLocalStorage.snapshot method is a static method in Node.js [1][2][3]. It captures the current execution context and returns a function that, when called, executes the provided function within that captured context [1][4]. It is designed to be called directly on the AsyncLocalStorage class, rather than on an instance of the class [1][5].

Citations:


Don’t freeze the “clean” detached AsyncLocalStorage runner at module init

runInRootAsyncContext is initialized at module evaluation time by calling AsyncLocalStorage.snapshot() and then reused for every detached call (maybeRunInSeparateTrace(..., ...) uses it when keepCallerTrace is false). If src/internal/misc.ts is first imported while a request-scoped trace store is active, the “clean” runner will replay that active request context (e.g., trace id "A"), defeating the detachment goal and pinning background work to the wrong trace.

Regression idea: import src/internal/misc.ts inside als.run({ traceId: "A" }, () => /* dynamic import */) and assert that maybeRunInSeparateTrace(false, async () => ...) does not observe "A".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/internal/misc.ts` around lines 403 - 420, The current code captures
AsyncLocalStorage.snapshot() at module load into runInRootAsyncContext, which
can freeze whatever async context is active during import; change it so
snapshot() is invoked at use-time instead of at module init: replace the frozen
const runInRootAsyncContext value with a function that, when called, checks
typeof AsyncLocalStorage?.snapshot === "function" and calls
AsyncLocalStorage.snapshot() to obtain a fresh detached runner (falling back to
running func() inline if snapshot is unavailable), and update
maybeRunInSeparateTrace to call this function each time when keepCallerTrace is
false so background work never replays the import-time context.


/**
* Runs an async function detached from the async context (and thus from the
* APM/tracing trace id, if any) that is active at the call site: the callback
* runs in the "clean" context that was captured at module load time instead.
* Each call starts its own fresh async subtree rooted in that clean context, so:
* - async_hooks-based tracers observe each call as a separate, brand-new trace
* (the tracer sees no active trace and starts a new one), and
* - all the async work spawned within a single call shares that one trace.
*
* This is used for background loops (e.g. Shards rediscovery) which are kicked
* off lazily by the very first query: without this, every subsequent timer-
* triggered loop iteration would forever remain attached to (and would thus
* pollute) the trace id of that very first query.
*/
export async function maybeRunInSeparateTrace<T>(keepCallerTrace: boolean, func: () => Promise<T>): Promise<T> {
return keepCallerTrace ? func() : runInRootAsyncContext(func);
}

/**
* A typesafe-way to invariant the object's key presence and being
* non-undefined. It is not always working for union types: sometimes it asserts
Expand Down
Loading