Skip to content

v3.0.2: run shards rediscovery in separate traces - #4

Merged
dimikot merged 1 commit into
grok/dimikot/move-mit-source-code-and-api-docs-into-this-repo-to-main-014efrom
grok/dimikot/v3-0-2-run-shards-rediscovery-in-separate-traces-to-main-fedc
Jun 8, 2026
Merged

v3.0.2: run shards rediscovery in separate traces#4
dimikot merged 1 commit into
grok/dimikot/move-mit-source-code-and-api-docs-into-this-repo-to-main-014efrom
grok/dimikot/v3-0-2-run-shards-rediscovery-in-separate-traces-to-main-fedc

Conversation

@dimikot

@dimikot dimikot commented Jun 8, 2026

Copy link
Copy Markdown
Owner

PRs in the Stack

(The stack is managed by git-grok.)

Summary by CodeRabbit

  • Chores

    • Bumped version to 3.0.2.
    • Updated development environment configuration.
  • Internal Improvements

    • Enhanced refresh operation handling for improved async context management.

@dimikot
dimikot force-pushed the grok/dimikot/v3-0-2-run-shards-rediscovery-in-separate-traces-to-main-fedc branch from fedc5d5 to dffefaf Compare June 8, 2026 05:16
Repository owner deleted a comment from coderabbitai Bot Jun 8, 2026
@dimikot

dimikot commented Jun 8, 2026

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Ignoring CodeRabbit configuration file changes. For security, only the configuration from the base branch is applied for open source repositories.

📝 Walkthrough

Walkthrough

This PR introduces async trace isolation for CachedRefreshedValue refresh operations. A new maybeRunInSeparateTrace helper conditionally executes async functions in either the current trace context or a detached root context. The refresh loop now tracks whether execution should inherit the caller's trace, with initial refreshes keeping the caller trace and automatic timer/dependency-triggered refreshes running detached. Tests verify both behaviors using AsyncLocalStorage.

Changes

Async Trace Isolation for Cached Refresh Operations

Layer / File(s) Summary
Async trace isolation helper
src/internal/misc.ts
New maybeRunInSeparateTrace function uses AsyncLocalStorage.snapshot() to execute async callbacks in either the current context (preserving caller trace) or a captured root context (detaching from caller trace), with fallback for environments without AsyncLocalStorage support.
CachedRefreshedValue trace control
src/internal/CachedRefreshedValue.ts
Imports and integrates maybeRunInSeparateTrace. Introduces keepCallerTrace flag initialized to true for the first refresh, then set to false for timer/deps-triggered refreshes. Wraps deps.handler() and resolverFn() execution to conditionally use detached tracing; skipDelay() resets the flag and resolves pending delays.
Async trace isolation test coverage
src/internal/__tests__/CachedRefreshedValue.test.ts
Adds AsyncLocalStorage-based test cases: one verifying that initial cached() refreshes inherit the caller's trace while subsequent timer-triggered refreshes run with unique detached trace ids; another asserting that refreshAndWait() calls preserve the caller's trace across multiple refreshes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

A trace once tangled, now set free,
Detached from callers' symphony.
Yet fresh beginnings hold the line—
Each refresh chooses when to shine. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the PR - implementing separate trace execution for shards rediscovery - which aligns with the changes to CachedRefreshedValue and helper functions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch grok/dimikot/v3-0-2-run-shards-rediscovery-in-separate-traces-to-main-fedc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/internal/CachedRefreshedValue.ts (1)

169-183: ⚠️ Potential issue | 🟠 Major

Detach deps.handler() inside depsTimeoutBody to prevent caller trace leakage

depsTimeoutBody() (lines ~169-183) schedules a setTimeout callback that runs this.options.deps.handler() via runInVoid(...) only, without using maybeRunInSeparateTrace(...). As a result, timer-driven dependency rechecks can keep inheriting the AsyncLocalStorage trace context of whoever started refreshLoop, even though the subsequent refresh’s depsPrev + resolverFn() are detached when keepCallerTrace is false. Wrap the deps.handler() call in the same detached runner used for automatic refreshes.

🤖 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 169 - 183, depsTimeoutBody
currently calls this.options.deps.handler() inside runInVoid, causing
timer-driven dependency checks to inherit the caller AsyncLocalStorage trace;
modify depsTimeoutBody (the function assigned to depsTimeoutBody) to invoke the
deps handler using the same detached runner used elsewhere (i.e., call
maybeRunInSeparateTrace or the existing detached wrapper used for
resolverFn/automatic refreshes) so that when keepCallerTrace is false the
deps.handler() runs detached; preserve existing try/catch/finally behavior and
still call this.onError(...) and setTimeout(...).unref() as before.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/internal/CachedRefreshedValue.ts`:
- Around line 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).

In `@src/internal/misc.ts`:
- Around line 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.

---

Outside diff comments:
In `@src/internal/CachedRefreshedValue.ts`:
- Around line 169-183: depsTimeoutBody currently calls
this.options.deps.handler() inside runInVoid, causing timer-driven dependency
checks to inherit the caller AsyncLocalStorage trace; modify depsTimeoutBody
(the function assigned to depsTimeoutBody) to invoke the deps handler using the
same detached runner used elsewhere (i.e., call maybeRunInSeparateTrace or the
existing detached wrapper used for resolverFn/automatic refreshes) so that when
keepCallerTrace is false the deps.handler() runs detached; preserve existing
try/catch/finally behavior and still call this.onError(...) and
setTimeout(...).unref() as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 19bec214-97ce-4611-b970-69ab1aeb6de5

📥 Commits

Reviewing files that changed from the base of the PR and between b97992c and dffefaf.

📒 Files selected for processing (6)
  • .coderabbit.yaml
  • .vscode/settings.json
  • package.json
  • src/internal/CachedRefreshedValue.ts
  • src/internal/__tests__/CachedRefreshedValue.test.ts
  • src/internal/misc.ts

Comment on lines +114 to +122
// 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;

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).

Comment thread src/internal/misc.ts
Comment on lines +403 to +420
/**
* 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();

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.

@dimikot
dimikot merged commit f671c9f into grok/dimikot/move-mit-source-code-and-api-docs-into-this-repo-to-main-014e Jun 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant