Skip to content

Commit 92ea7c2

Browse files
committed
[Cache Components] Atomic setTimeouts
1 parent e5e80ea commit 92ea7c2

File tree

4 files changed

+185
-21
lines changed

4 files changed

+185
-21
lines changed

packages/next/errors.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -907,5 +907,7 @@
907907
"906": "Bindings not loaded yet, but they are being loaded, did you forget to await?",
908908
"907": "bindings not loaded yet. Either call `loadBindings` to wait for them to be available or ensure that `installBindings` has already been called.",
909909
"908": "Invalid flags should be run as node detached-flush dev ./path-to/project [eventsFile]",
910-
"909": "Failed to load SWC binary for %s/%s, see more info here: https://nextjs.org/docs/messages/failed-loading-swc"
910+
"909": "Failed to load SWC binary for %s/%s, see more info here: https://nextjs.org/docs/messages/failed-loading-swc",
911+
"910": "An unexpected error occurred while adjusting `_idleStart` on an atomic timer",
912+
"911": "createAtomicTimerGroup cannot be called in the edge runtime"
911913
}

packages/next/src/server/app-render/app-render-prerender-utils.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InvariantError } from '../../shared/lib/invariant-error'
2+
import { createAtomicTimerGroup } from './app-render-scheduling'
23

34
/**
45
* This is a utility function to make scheduling sequential tasks that run back to back easier.
@@ -14,19 +15,22 @@ export function prerenderAndAbortInSequentialTasks<R>(
1415
)
1516
} else {
1617
return new Promise((resolve, reject) => {
18+
const scheduleTimeout = createAtomicTimerGroup()
19+
1720
let pendingResult: Promise<R>
18-
setTimeout(() => {
21+
scheduleTimeout(() => {
1922
try {
2023
pendingResult = prerender()
2124
pendingResult.catch(() => {})
2225
} catch (err) {
2326
reject(err)
2427
}
25-
}, 0)
26-
setTimeout(() => {
28+
})
29+
30+
scheduleTimeout(() => {
2731
abort()
2832
resolve(pendingResult)
29-
}, 0)
33+
})
3034
})
3135
}
3236
}
@@ -46,22 +50,26 @@ export function prerenderAndAbortInSequentialTasksWithStages<R>(
4650
)
4751
} else {
4852
return new Promise((resolve, reject) => {
53+
const scheduleTimeout = createAtomicTimerGroup()
54+
4955
let pendingResult: Promise<R>
50-
setTimeout(() => {
56+
scheduleTimeout(() => {
5157
try {
5258
pendingResult = prerender()
5359
pendingResult.catch(() => {})
5460
} catch (err) {
5561
reject(err)
5662
}
57-
}, 0)
58-
setTimeout(() => {
63+
})
64+
65+
scheduleTimeout(() => {
5966
advanceStage()
60-
}, 0)
61-
setTimeout(() => {
67+
})
68+
69+
scheduleTimeout(() => {
6270
abort()
6371
resolve(pendingResult)
64-
}, 0)
72+
})
6573
})
6674
}
6775
}

packages/next/src/server/app-render/app-render-render-utils.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { InvariantError } from '../../shared/lib/invariant-error'
2+
import { createAtomicTimerGroup } from './app-render-scheduling'
23

34
/**
45
* This is a utility function to make scheduling sequential tasks that run back to back easier.
@@ -14,18 +15,21 @@ export function scheduleInSequentialTasks<R>(
1415
)
1516
} else {
1617
return new Promise((resolve, reject) => {
18+
const scheduleTimeout = createAtomicTimerGroup()
19+
1720
let pendingResult: R | Promise<R>
18-
setTimeout(() => {
21+
scheduleTimeout(() => {
1922
try {
2023
pendingResult = render()
2124
} catch (err) {
2225
reject(err)
2326
}
24-
}, 0)
25-
setTimeout(() => {
27+
})
28+
29+
scheduleTimeout(() => {
2630
followup()
2731
resolve(pendingResult)
28-
}, 0)
32+
})
2933
})
3034
}
3135
}
@@ -46,19 +50,21 @@ export function pipelineInSequentialTasks<A, B, C>(
4650
)
4751
} else {
4852
return new Promise((resolve, reject) => {
53+
const scheduleTimeout = createAtomicTimerGroup()
54+
4955
let oneResult: A | undefined = undefined
50-
setTimeout(() => {
56+
scheduleTimeout(() => {
5157
try {
5258
oneResult = one()
5359
} catch (err) {
5460
clearTimeout(twoId)
5561
clearTimeout(threeId)
5662
reject(err)
5763
}
58-
}, 0)
64+
})
5965

6066
let twoResult: B | undefined = undefined
61-
const twoId = setTimeout(() => {
67+
const twoId = scheduleTimeout(() => {
6268
// if `one` threw, then this timeout would've been cleared,
6369
// so if we got here, we're guaranteed to have a value.
6470
try {
@@ -67,17 +73,17 @@ export function pipelineInSequentialTasks<A, B, C>(
6773
clearTimeout(threeId)
6874
reject(err)
6975
}
70-
}, 0)
76+
})
7177

72-
const threeId = setTimeout(() => {
78+
const threeId = scheduleTimeout(() => {
7379
// if `two` threw, then this timeout would've been cleared,
7480
// so if we got here, we're guaranteed to have a value.
7581
try {
7682
resolve(three(twoResult!))
7783
} catch (err) {
7884
reject(err)
7985
}
80-
}, 0)
86+
})
8187
})
8288
}
8389
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { InvariantError } from '../../shared/lib/invariant-error'
2+
3+
/*
4+
==========================
5+
| Background |
6+
==========================
7+
8+
Node.js does not guarantee that two timers scheduled back to back will run
9+
on the same iteration of the event loop:
10+
11+
```ts
12+
setTimeout(one, 0)
13+
setTimeout(two, 0)
14+
```
15+
16+
Internally, each timer is assigned a `_idleStart` property that holds
17+
an internal libuv timestamp in millisecond resolution.
18+
This will be used to determine if the timer is already "expired" and should be executed.
19+
However, even in sync code, it's possible for two timers to get different `_idleStart` values.
20+
This cause one of the timers to be executed, and the other to be delayed until the next timer phase.
21+
22+
The delaying happens [here](https://github.com/nodejs/node/blob/c208ffc66bb9418ff026c4e3fa82e5b4387bd147/lib/internal/timers.js#L556-L564).
23+
and can be debugged by running node with `NODE_DEBUG=timer`.
24+
25+
The easiest way to observe it is to run this program in a loop until it exits with status 1:
26+
27+
```
28+
// test.js
29+
30+
let immediateRan = false
31+
const t1 = setTimeout(() => {
32+
console.log('timeout 1')
33+
setImmediate(() => {
34+
console.log('immediate 1')
35+
immediateRan = true
36+
})
37+
})
38+
39+
const t2 = setTimeout(() => {
40+
console.log('timeout 2')
41+
if (immediateRan) {
42+
console.log('immediate ran before the second timeout!')
43+
console.log(
44+
`t1._idleStart: ${t1._idleStart}, t2_idleStart: ${t2._idleStart}`
45+
);
46+
process.exit(1)
47+
}
48+
})
49+
```
50+
51+
```bash
52+
#!/usr/bin/env bash
53+
54+
i=1;
55+
while true; do
56+
output="$(NODE_DEBUG=timer node test.js 2>&1)";
57+
if [ "$?" -eq 1 ]; then
58+
echo "failed after $i iterations";
59+
echo "$output";
60+
break;
61+
fi;
62+
i=$((i+1));
63+
done
64+
```
65+
66+
If `t2` is deferred to the next iteration of the event loop,
67+
then the immediate scheduled from inside `t1` will run first.
68+
When this occurs, `_idleStart` is reliably different between `t1` and `t2`.
69+
70+
==========================
71+
| Solution |
72+
==========================
73+
74+
We can guarantee that multiple timers (with the same delay, usually `0`)
75+
run together without any delays by making sure that their `_idleStart`s are the same,
76+
because that's what's used to determine if a timer should be deferred or not.
77+
Luckily, this is property is currently exposed to userland and mutable,
78+
so we can patch it.
79+
80+
Another related trick we could potentially apply is making
81+
a timer immediately be considered expired by doing `timer._idleStart -= 2`.
82+
(the value must be more `1`, the delay that actually gets set for `setTimeout(cb, 0)`).
83+
This makes node view this timer as "a 1ms timer scheduled 2ms ago",
84+
meaning that it should definitely run in the next timer phase.
85+
However, I'm not confident we know all the side effects of doing this,
86+
so for now, simply ensuring coordination is enough.
87+
*/
88+
89+
let cannotGuaranteeAtomicTimers = false
90+
91+
/**
92+
* Allows scheduling multiple timers (equivalent to `setTimeout(cb, delayMs)`)
93+
* that are guaranteed to run in the same iteration of the event loop.
94+
*
95+
* @param delayMs - the delay to pass to `setTimeout`. (default: 0)
96+
*
97+
* */
98+
export function createAtomicTimerGroup(delayMs = 0) {
99+
if (process.env.NEXT_RUNTIME === 'edge') {
100+
throw new InvariantError(
101+
'createAtomicTimerGroup cannot be called in the edge runtime'
102+
)
103+
} else {
104+
let firstTimerIdleStart: number | null = null
105+
106+
return function scheduleTimeout(callback: () => void) {
107+
const timer = setTimeout(callback, delayMs)
108+
if (cannotGuaranteeAtomicTimers) {
109+
// We already tried patching some timers, and it didn't work.
110+
// No point trying again.
111+
return timer
112+
}
113+
114+
// NodeJS timers to have a `_idleStart` property, but it doesn't exist e.g. in Bun.
115+
// If it's not present, we'll warn and try to continue.
116+
try {
117+
if ('_idleStart' in timer && typeof timer._idleStart === 'number') {
118+
// If this is the first timer that was scheduled, save its `_idleStart`.
119+
// We'll copy it onto subsequent timers to guarantee that they'll all be
120+
// considered expired in the same iteration of the event loop
121+
// and thus will all be executed in the same timer phase.
122+
if (firstTimerIdleStart === null) {
123+
firstTimerIdleStart = timer._idleStart
124+
} else {
125+
timer._idleStart = firstTimerIdleStart
126+
}
127+
} else {
128+
console.warn(
129+
"Next.js cannot guarantee that Cache Components will run as expected due to the current runtime's implementation of `setTimeout()`.\nPlease report a github issue here: https://github.com/vercel/next.js/issues/new/"
130+
)
131+
cannotGuaranteeAtomicTimers = true
132+
}
133+
} catch (err) {
134+
// This should never fail in current Node, but it might start failing in the future.
135+
// We might be okay even without tweaking the timers, so warn and try to continue.
136+
console.error(
137+
new InvariantError(
138+
'An unexpected error occurred while adjusting `_idleStart` on an atomic timer',
139+
{ cause: err }
140+
)
141+
)
142+
cannotGuaranteeAtomicTimers = true
143+
}
144+
145+
return timer
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)