caching-tools / next-shared-cache

Next.js self-hosting simplified
https://caching-tools.github.io/next-shared-cache/
MIT License
323 stars 22 forks source link

Using redis-stack & lru makes static assets 404 #703

Open bitttttten opened 2 months ago

bitttttten commented 2 months ago

Brief Description of the Bug

When using

import createLruHandler from '@neshca/cache-handler/local-lru'
import createRedisHandler from '@neshca/cache-handler/redis-stack'

We see that our static assets like JS and CSS files sometimes 404.

Screenshot 2024-08-17 at 18 37 35

Screenshot 2024-08-17 at 18 36 03

So not all, but some.

Severity Maybe Major?

Frequency of Occurrence Always

Environment:

It happens on CircleCI and local on M3 and M1.

Dependencies and Versions next@14.2.3 @neshca/cache-handler@1.5.1

Attempted Solutions or Workarounds None so far, we removed the package and continuing using our own redis cache handler.

Impact of the Bug Critical, we cannot get Next.JS assets loaded.

Additional context None, but happy to provide more!

mauroaccornero commented 2 months ago

@bitttttten can you share the next.config file and the file that it's using the imports you mentioned?

bitttttten commented 2 months ago

@mauroaccornero sure!

// redis.mjs
import { CacheHandler } from '@neshca/cache-handler'
import createLruHandler from '@neshca/cache-handler/local-lru'
import createRedisHandler from '@neshca/cache-handler/redis-stack'
import invariant from 'invariant'
import { createClient } from 'redis'

const REDIS_URL = process.env.REDIS_URL

invariant(REDIS_URL, 'REDIS_URL is required inside redis.mjs')

CacheHandler.onCreation(async () => {
  let client

  try {
    client = createClient({
      url: REDIS_URL,
    })

    // Redis won't work without error handling. https://github.com/redis/node-redis?tab=readme-ov-file#events
    client.on('error', error => {
      if (process.env.NEXT_PRIVATE_DEBUG_CACHE === '1') {
        // Use logging with caution in production. Redis will flood your logs. Hide it behind a flag.
        console.error('[cache-handler-redis] Redis client error:', error)
      }
    })
  } catch (error) {
    console.warn('[cache-handler-redis] Failed to create Redis client:', error)
  }

  if (client) {
    try {
      console.info('[cache-handler-redis] Connecting Redis client...')
      await client.connect()
      console.info('[cache-handler-redis] Redis client connected.')
    } catch (error) {
      console.warn('[cache-handler-redis] Failed to connect Redis client:', error)

      console.warn('[cache-handler-redis] Disconnecting the Redis client...')
      client
        .disconnect()
        .then(() => {
          console.info('[cache-handler-redis] Redis client disconnected.')
        })
        .catch(() => {
          console.warn(
            '[cache-handler-redis] Failed to quit the Redis client after failing to connect.',
          )
        })
    }
  }

  /** @type {import("@neshca/cache-handler").Handler | null} */
  let handler

  if (client?.isReady) {
    handler = await createRedisHandler({
      client,
      keyPrefix: `${process.env.PROJECT_NAME || 'next'}:`,
      timeoutMs: 1000,
    })
  } else {
    handler = createLruHandler()
    console.warn(
      '[cache-handler-redis] Falling back to LRU handler because Redis client is not available.',
    )
  }

  return {
    handlers: [handler],
  }
})

export default CacheHandler

Our next.config.mjs looks like

import { generateConfig } from "next-configs/base.mjs";
import nextIntl from "next-intl/plugin";
import * as Sentry from "@sentry/nextjs";
import merge from "lodash.merge";
import bundleAnalyzer from '@next/bundle-analyzer'
import { getAssetPrefix, hasAssetPrefix } from '@/utils/asset-prefix.mjs'

function generateConfig(userConfig = {}) {
  const assetPrefix = hasAssetPrefix() ? getAssetPrefix() : undefined;

  /**
   * @type {NextConfig}
   */
  const base = {
    logging: {
      fetches: {
        fullUrl: true,
      },
    },
    assetPrefix,
    reactStrictMode: true,
    typescript: {
      ignoreBuildErrors: true,
    },
    eslint: {
      ignoreDuringBuilds: true,
    },
    trailingSlash: true,
    transpilePackages: [],
    experimental: {
      instrumentationHook: true,
      optimizePackageImports: [
        "@radix-ui/primitive",
        "@radix-ui/react-avatar",
        "@radix-ui/react-tooltip",
        "@radix-ui/react-dialog",
        "@radix-ui/react-popover",
        "@radix-ui/react-select",
        "@radix-ui/react-slider",
        "@radix-ui/react-switch",
        "@radix-ui/react-radio-group",
        "@radix-ui/react-toolbar",
        "@radix-ui/react-tooltip",
      ],
    },
  };

  const cacheHandler = {
    cacheHandler: require.resolve("./redis.mjs"),
    cacheMaxMemorySize: 0,
  };

  const config = merge(base, cacheHandler, userConfig);

  if (process.env.ANALYZE === "true") {
    const withBundleAnalyzer = bundleAnalyzer({
      openAnalyzer: true,
    });

    return withBundleAnalyzer(config);
  }

  return config;
}

const base = generateConfig({
  images: {
    loader: "custom",
    loaderFile: "./custom-image-loader.ts",
    remotePatterns: [],
  },
  async rewrites() {
    return {
      beforeFiles: [
        // some redirects
      ],
    };
  },
  async headers() {
    return [
      {
        // adds some custom headers
      },
    ];
  },
});

const withNextIntl = nextIntl("./i18n.ts");

const sentryWebpackPluginOptions = {
  org: process.env.SENTRY_ORG,
  project: process.env.SENTRY_PROJECT,
  authToken: process.env.SENTRY_AUTH_TOKEN,
  silent: true,
  hideSourceMaps: false,
  disableLogger: true,
  sourceMaps: {
    disable: true,
  },
  unstable_sentryWebpackPluginOptions: {
    applicationKey: "*****",
  },
};

export default Sentry.withSentryConfig(
  withNextIntl(base),
  sentryWebpackPluginOptions
);

Happy to share more info if you need.

mauroaccornero commented 2 months ago

@bitttttten thanks for the files.

if you run the build command on your machine do you get any error?

npm run build

It's weird to me the require.resolve used like that in a mjs file.

For a mjs file to use require I normally do something like this:

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

if you want you can check this example

I suggest to avoid using the cache handler when not in production, you can do that in your next.config file

cacheHandler:
        process.env.NODE_ENV === "production"
            ? require.resolve("./redis.mjs")
            : undefined

another suggestion is to not use redis cache when building, you can add an if before the try catch in your redis.mjs file

import { PHASE_PRODUCTION_BUILD }  from "next/constants.js";
 if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
(...)
}

maybe try to run the build and start command on your machine to see if you get any error

bitttttten commented 2 months ago

Ah sorry I am stripping a bunch of things out as they are sensitive to the project. Indeed we have require resolve already:


import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)

  const cacheHandler = {
    cacheHandler:
      process.env.REDIS_HOSTNAME && process.env.REDIS_PASSWORD
        ? require.resolve('./cache-handler-redis.mjs')
        : require.resolve('./cache-handler-noop.js'),
    cacheMaxMemorySize: 0,
  }

and now we have this.

I looked into disabling the handler in the production build phase already like

// redis.mjs

class CacheHandler {
///
}

const cache = new Map()

class CacheHandlerMemory {
  async get(key) {
    return cache.get(key)
  }
  async set(key, data, ctx) {
    cache.set(key, {
      value: data,
      lastModified: Date.now(),
      tags: ctx.tags,
    })
  }
  async revalidateTag(tag) {
    for (let [key, value] of cache) {
      if (value.tags.includes(tag)) {
        cache.delete(key)
      }
    }
  }
}

const loadInMemory = process.env.NEXT_PHASE === 'phase-production-build'

console.log(`Cache handler: ${loadInMemory ? 'Memory' : 'Redis'}`)

export default loadInMemory ? CacheHandlerMemory : CacheHandler

And still no bueno yet. I don't get any errors, the nextjs app compiles, it just fails to load the css when starting. If there is an error, it's getting swallowed somewhere..

mauroaccornero commented 2 months ago

Maybe you can try to replace CacheHandlerMemory with undefined. Just to skip any additional code running during the build and focus on the main cacheHandler. Maybe try to run the build and start with NEXT_PRIVATE_DEBUG_CACHE=1 to see what the cacheHandler is doing and when. Another option could be to use the example cacheHandler from next.js repository and see if you get some new error or warning.

bitttttten commented 2 months ago

We tried the example cacheHandler from the repo and running in the same problem. What we have done is disable the cache handler on our CI environments and locally, which is where the problem was. When we build on Vercel or inside a Dockerfile, curiously this issue is not there. I don't have much more time to debug it since I've spent maybe 8 hrs trying different combinations and debugging. And since we can disable them on CI and locally, we fingers crossed no longer see the issue. If it comes up i'll look into NEXT_PRIVATE_DEBUG_CACHE 🥳 thanks for the tip!

better-salmon commented 2 months ago

@bitttttten hello! Does your app live in a monorepo?

bitttttten commented 2 months ago

yes!

better-salmon commented 2 months ago

I've had issues with assets in a monorepo in the past. Please ensure that the problem isn't caused by misconfigured tracing. Refer to this Next.js documentation on outputFileTracingRoot at https://nextjs.org/docs/app/api-reference/next-config-js/output#caveats.

bitttttten commented 2 months ago

oh interesting, that was actually my next thing to attempt to look into! when i manage to implement the outputFileTracingRoot and see it's affect, i will let you know. thanks

better-salmon commented 2 months ago

@bitttttten hello! Did the outputFileTracingRoot option help?

whizzzkid commented 1 month ago

I am running into similar issues, is there a recommended fix or the known root-cause of why this happens?

I am also seeing scenarios where redeploying changes seem to make the pages go 404 and those never get revalidated unless, I manually revalidate each page.

better-salmon commented 1 month ago

@whizzzkid hi! Which Next.js Router do you use in your project?

JSONRice commented 1 month ago

@bitttttten thanks for the files.

if you run the build command on your machine do you get any error?

npm run build

It's weird to me the require.resolve used like that in a mjs file.

For a mjs file to use require I normally do something like this:

import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

if you want you can check this example

I suggest to avoid using the cache handler when not in production, you can do that in your next.config file

cacheHandler:
        process.env.NODE_ENV === "production"
            ? require.resolve("./redis.mjs")
            : undefined

another suggestion is to not use redis cache when building, you can add an if before the try catch in your redis.mjs file

import { PHASE_PRODUCTION_BUILD }  from "next/constants.js";
 if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
(...)
}

maybe try to run the build and start command on your machine to see if you get any error

@mauroaccornero the link to your example is no longer available:

mauroaccornero commented 1 month ago

@JSONRice it's public now

ramonmalcolm10 commented 4 weeks ago

Am facing this issue as well, when the application is redeployed, it is using the previous cache version of a static page which reference assets from the previous deployment. Is there away to ensure the cache is purged or is some configuration missing on my end? Would this config cause any issue, it is recommended by Next.js cacheMaxMemorySize: isProd ? 0 : undefined