Repository: vercel/next.js
Pull request: Improve Incremental Cache Locking Algorithm

This improves the locking algorithm used by the incremental cache so that it will retry to get a lock after the lock is released. This corrects the behaviour that existed previously where multiple lockers all acquired a lock after the first lock was released.

Changed files:
filename: packages/next/src/server/lib/incremental-cache/index.ts
patch: @@ -34,6 +34,7 @@ import { InvariantError } from '../../../shared/lib/invariant-error'
 import type { Revalidate } from '../cache-control'
 import { getPreviouslyRevalidatedTags } from '../../server-utils'
 import { workAsyncStorage } from '../../app-render/work-async-storage.external'
+import { DetachedPromise } from '../../../lib/detached-promise'
 
 export interface CacheHandlerContext {
   fs?: CacheFs
@@ -91,6 +92,8 @@ export class IncrementalCache implements IncrementalCacheType {
   readonly revalidatedTags?: string[]
   readonly isOnDemandRevalidate?: boolean
 
+  private static readonly debug: boolean =
+    !!process.env.NEXT_PRIVATE_DEBUG_CACHE
   private readonly locks = new Map<string, Promise<void>>()
 
   /**
@@ -124,7 +127,6 @@ export class IncrementalCache implements IncrementalCacheType {
     fetchCacheKeyPrefix?: string
     CurCacheHandler?: typeof CacheHandler
   }) {
-    const debug = !!process.env.NEXT_PRIVATE_DEBUG_CACHE
     this.hasCustomCacheHandler = Boolean(CurCacheHandler)
 
     const cacheHandlersSymbol = Symbol.for('@next/cache-handlers')
@@ -142,13 +144,13 @@ export class IncrementalCache implements IncrementalCacheType {
         CurCacheHandler = globalCacheHandler.FetchCache
       } else {
         if (fs && serverDistDir) {
-          if (debug) {
+          if (IncrementalCache.debug) {
             console.log('using filesystem cache handler')
           }
           CurCacheHandler = FileSystemCache
         }
       }
-    } else if (debug) {
+    } else if (IncrementalCache.debug) {
       console.log('using custom cache handler', CurCacheHandler.name)
     }
 
@@ -234,23 +236,42 @@ export class IncrementalCache implements IncrementalCacheType {
     this.cacheHandler?.resetRequestCache?.()
   }
 
-  async lock(cacheKey: string) {
-    let unlockNext: () => Promise<void> = () => Promise.resolve()
-    const existingLock = this.locks.get(cacheKey)
+  async lock(cacheKey: string): Promise<() => Promise<void> | void> {
+    // Wait for any existing lock on this cache key to be released
+    // This implements a simple queue-based locking mechanism
+    while (true) {
+      const lock = this.locks.get(cacheKey)
 
-    if (existingLock) {
-      await existingLock
+      if (IncrementalCache.debug) {
+        console.log('lock get', cacheKey, !!lock)
+      }
+
+      // If no lock exists, we can proceed to acquire it
+      if (!lock) break
+
+      // Wait for the existing lock to be released before trying again
+      await lock
     }
 
-    const newLock = new Promise<void>((resolve) => {
-      unlockNext = async () => {
-        resolve()
-        this.locks.delete(cacheKey) // Remove the lock upon release
-      }
-    })
+    // Create a new detached promise that will represent this lock
+    // The resolve function (unlock) will be returned to the caller
+    const { resolve, promise } = new DetachedPromise<void>()
 
-    this.locks.set(cacheKey, newLock)
-    return unlockNext
+    if (IncrementalCache.debug) {
+      console.log('successfully locked', cacheKey)
+    }
+
+    // Store the lock promise in the locks map
+    this.locks.set(cacheKey, promise)
+
+    return () => {
+      // Resolve the promise to release the lock.
+      resolve()
+
+      // Remove the lock from the map once it's released so that future gets
+      // can acquire the lock.
+      this.locks.delete(cacheKey)
+    }
   }
 
   async revalidateTag(tags: string | string[]): Promise<void> {

filename: packages/next/src/server/lib/patch-fetch.test.ts
patch: @@ -41,7 +41,7 @@ describe('createPatchedFetcher', () => {
       get: jest.fn(),
       set: jest.fn(() => resolveIncrementalCacheSet()),
       generateCacheKey: jest.fn(() => 'test-cache-key'),
-      lock: jest.fn(() => resolveIncrementalCacheSet),
+      lock: jest.fn(() => () => {}),
     } as unknown as IncrementalCache
 
     // We only need to provide a few of the WorkStore properties.

filename: packages/next/src/server/lib/patch-fetch.ts
patch: @@ -573,7 +573,7 @@ export function createPatchedFetcher(
         const fetchIdx = workStore.nextFetchId ?? 1
         workStore.nextFetchId = fetchIdx + 1
 
-        let handleUnlock = () => Promise.resolve()
+        let handleUnlock: () => Promise<void> | void = () => {}
 
         const doOriginalFetch = async (
           isStale?: boolean,