Skip to content
Closed
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
146 changes: 146 additions & 0 deletions docs/overview/cache-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,149 @@ export default defineMultiCacheOptions(() => {
```

:::

## Cache Tag Invalidator

The _Cache Tag Invalidator_ is responsible for managing the invalidation of
cache tags. When cache tags are added via the `add()` method, the invalidator
decides when and how to invalidate the corresponding cache items.

By default, the module uses an in-memory invalidator with debounced invalidation
to prevent performance issues from too many invalidation requests at once.

### Using the built-in in-memory invalidator

The built-in invalidator is used by default. You can explicitly configure it:

::: code-group

```typescript [~/server/multiCache.serverOptions.ts]
import { defineMultiCacheOptions } from 'nuxt-multi-cache/server-options'

export default defineMultiCacheOptions(() => {
return {
cacheTagInvalidator: 'in-memory',
}
})
```

:::

### Custom Implementation

Provide a factory function in `cacheTagInvalidator` that returns an object
implementing the [type.CacheTagInvalidator] interface.

The factory function receives:

- `cache` - All cache instances (data, route, component)
- `cacheTagRegistry` - The configured cache tag registry (if any)

This allows you to:

- Store the tags buffer in custom storage (MongoDB, Redis, etc.)
- Implement custom invalidation logic (debounce, queue, immediate, etc.)
- Add logging, metrics, or monitoring
- Integrate with external invalidation systems

You can use the built-in in-memory invalidator implementation as a reference:

<<< @/../src/runtime/helpers/InMemoryCacheTagInvalidator.ts

::: code-group

```typescript [~/server/lib/MongoCacheTagInvalidator.ts]
import type {
CacheTagInvalidator,
MultiCacheInstances,
CacheTagRegistry,
} from 'nuxt-multi-cache'

export class MongoCacheTagInvalidator implements CacheTagInvalidator {
private invalidationTimeout: NodeJS.Timeout | null = null

constructor(
private cache: MultiCacheInstances,
private registry: CacheTagRegistry | null,
private mongoCollection: Collection,
) {}

add(tags: string[]): void {
// Store tags in MongoDB for persistence across restarts
this.mongoCollection.insertMany(
tags.map((tag) => ({
tag,
timestamp: Date.now(),
status: 'pending',
})),
)

// Schedule invalidation with custom delay
if (!this.invalidationTimeout) {
this.invalidationTimeout = setTimeout(() => {
this.invalidate()
}, 5000) // Custom 5 second delay
}
}

private async invalidate() {
// Get pending tags from MongoDB
const pendingTags = await this.mongoCollection
.find({ status: 'pending' })
.toArray()

const tags = pendingTags.map((doc) => doc.tag)

// Use registry if available for efficient invalidation
if (this.registry) {
const invalidationMap = await this.registry.getCacheKeysForTags(tags)

for (const [cacheType, keys] of Object.entries(invalidationMap)) {
const cache = this.cache[cacheType as keyof MultiCacheInstances]
if (cache) {
for (const key of keys) {
await cache.storage.removeItem(key)
}
}
}

await this.registry.removeTags(tags)
}

// Mark tags as processed in MongoDB
await this.mongoCollection.updateMany(
{ status: 'pending' },
{ $set: { status: 'processed', processedAt: Date.now() } },
)

this.invalidationTimeout = null
}
}
```

```typescript [~/server/multiCache.serverOptions.ts]
import { defineMultiCacheOptions } from 'nuxt-multi-cache/server-options'
import { MongoCacheTagInvalidator } from './lib/MongoCacheTagInvalidator'
import { getMongoCollection } from './lib/mongo'

export default defineMultiCacheOptions(() => {
return {
cacheTagInvalidator: (cache, cacheTagRegistry) => {
const mongoCollection = getMongoCollection('cache_tags')
return new MongoCacheTagInvalidator(cache, cacheTagRegistry, mongoCollection)
},
}
})
```

:::

::: warning Multiple Instances

When running your app in multiple instances (e.g. via
[PM2 Cluster Mode](https://pm2.keymetrics.io/docs/usage/cluster-mode/)), make
sure your custom invalidator implementation can handle cross-instance
synchronization. The built-in in-memory invalidator only works within a single
instance.

:::
1 change: 1 addition & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { pathToFileURL } from 'node:url'
import { parseURL } from 'ufo'

export type { CacheTagRegistry } from './runtime/types/CacheTagRegistry'
export type { CacheTagInvalidator } from './runtime/types/CacheTagInvalidator'
export type { CacheType } from './runtime/types/index'
export type { ModuleOptions }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cacheTagInvalidationDelay } from '#nuxt-multi-cache/config'
import type { CacheType, MultiCacheInstances } from '../types'
import type { CacheTagRegistry } from '../types/CacheTagRegistry'
import type { CacheTagInvalidator } from '../types/CacheTagInvalidator'
import {
decodeComponentCacheItem,
decodeRouteCacheItem,
Expand All @@ -14,7 +15,7 @@ import {
* could lead to performance issues since tag invalidation can be very
* inefficient.
*/
export class CacheTagInvalidator {
export class InMemoryCacheTagInvalidator implements CacheTagInvalidator {
/**
* Buffer of tags to invalidate in the next run.
*/
Expand Down
10 changes: 5 additions & 5 deletions src/runtime/server/plugins/multiCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from '#nuxt-multi-cache/config'
import { useRuntimeConfig } from '#imports'
import { InMemoryCacheTagRegistry } from '../../helpers/InMemoryCacheTagRegistry'
import { CacheTagInvalidator } from '../../helpers/CacheTagInvalidator'
import { InMemoryCacheTagInvalidator } from '../../helpers/InMemoryCacheTagInvalidator'

function createCacheContext(
cache: MultiCacheServerOptionsCacheOptions | undefined,
Expand Down Expand Up @@ -63,10 +63,10 @@ function createMultiCacheApp(): MultiCacheApp {
? new InMemoryCacheTagRegistry()
: (serverOptions.cacheTagRegistry ?? null)

const cacheTagInvalidator = new CacheTagInvalidator(
cacheContext,
cacheTagRegistry,
)
const cacheTagInvalidator =
typeof serverOptions.cacheTagInvalidator === 'function'
? serverOptions.cacheTagInvalidator(cacheContext, cacheTagRegistry)
: new InMemoryCacheTagInvalidator(cacheContext, cacheTagRegistry)

return {
cache: cacheContext,
Expand Down
13 changes: 13 additions & 0 deletions src/runtime/types/CacheTagInvalidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface CacheTagInvalidator {
/**
* Add cache tags to be invalidated.
*
* The implementation is responsible for:
* - Storing the tags (in-memory, MongoDB, Redis, etc.)
* - Managing the invalidation timing (debounce, queue, immediate, etc.)
* - Performing the actual invalidation logic
*
* @param tags - Array of cache tags to invalidate
*/
add(tags: string[]): void
}
34 changes: 33 additions & 1 deletion src/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { NuxtMultiCacheRouteCacheHelper } from './../helpers/RouteCacheHelp
import type { NuxtMultiCacheCDNHelper } from './../helpers/CDNHelper'
import type { MultiCacheState } from './../helpers/MultiCacheState'
import type { MaxAge } from './../helpers/maxAge'
import type { CacheTagInvalidator } from './../helpers/CacheTagInvalidator'
import type { CacheTagInvalidator } from './CacheTagInvalidator'
import type { CacheTagRegistry } from './CacheTagRegistry'

export type BubbleCacheability = boolean | 'route' | 'cdn'
Expand Down Expand Up @@ -34,6 +34,11 @@ export interface MultiCacheInstances {
route?: MultiCacheInstance
}

export type CacheTagInvalidatorFactory = (
cache: MultiCacheInstances,
cacheTagRegistry: CacheTagRegistry | null,
) => CacheTagInvalidator

export interface CacheItem {
data: string
expires: number
Expand Down Expand Up @@ -158,6 +163,33 @@ export type MultiCacheServerOptions = {
* whereas the in-memory cache registry is "gone" after a restart.
*/
cacheTagRegistry?: CacheTagRegistry | 'in-memory'

/**
* Define a custom cache tag invalidator.
*
* The invalidator is responsible for managing the invalidation of cache tags.
* It receives cache tags via the `add()` method and decides when and how to
* invalidate the corresponding cache items.
*
* By providing a custom invalidator, you can:
* - Store the tags buffer in custom storage (MongoDB, Redis, etc.)
* - Implement custom invalidation logic (debounce, queue, immediate, etc.)
* - Add logging, metrics, or monitoring
*
* The factory function receives the cache instances and the cache tag registry
* (if configured), allowing you to integrate with them as needed.
*
* If 'in-memory' is set or if this option is not provided, the built-in
* in-memory invalidator is used.
*
* @example
* ```typescript
* cacheTagInvalidator: (cache, cacheTagRegistry) => {
* return new MyCacheTagInvalidator(cache, cacheTagRegistry, mongoConnection)
* }
* ```
*/
cacheTagInvalidator?: CacheTagInvalidatorFactory | 'in-memory'
}

// This typo went unnoticed for quite some time, so we'll also export it with
Expand Down
28 changes: 14 additions & 14 deletions test/helpers/CacheTagInvalidator.node.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CacheTagInvalidator } from '~/src/runtime/helpers/CacheTagInvalidator'
import { InMemoryCacheTagInvalidator } from '~/src/runtime/helpers/InMemoryCacheTagInvalidator'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createStorage } from 'unstorage'
import type { MultiCacheInstances } from '~/src/runtime/types'
Expand All @@ -13,8 +13,8 @@ vi.mock('#nuxt-multi-cache/config', () => ({
isTestMode: true,
}))

describe('CacheTagInvalidator', () => {
let cacheTagInvalidator: CacheTagInvalidator
describe('InMemoryCacheTagInvalidator', () => {
let cacheTagInvalidator: InMemoryCacheTagInvalidator
let mockCacheTagRegistry: CacheTagRegistry
let cacheContext: MultiCacheInstances

Expand Down Expand Up @@ -60,24 +60,24 @@ describe('CacheTagInvalidator', () => {

describe('constructor', () => {
it('should initialize with cache instances and registry', () => {
cacheTagInvalidator = new CacheTagInvalidator(
cacheTagInvalidator = new InMemoryCacheTagInvalidator(
cacheContext,
mockCacheTagRegistry,
)

expect(cacheTagInvalidator).toBeInstanceOf(CacheTagInvalidator)
expect(cacheTagInvalidator).toBeInstanceOf(InMemoryCacheTagInvalidator)
})

it('should initialize with null registry', () => {
cacheTagInvalidator = new CacheTagInvalidator(cacheContext, null)
cacheTagInvalidator = new InMemoryCacheTagInvalidator(cacheContext, null)

expect(cacheTagInvalidator).toBeInstanceOf(CacheTagInvalidator)
expect(cacheTagInvalidator).toBeInstanceOf(InMemoryCacheTagInvalidator)
})
})

describe('add', () => {
beforeEach(() => {
cacheTagInvalidator = new CacheTagInvalidator(
cacheTagInvalidator = new InMemoryCacheTagInvalidator(
cacheContext,
mockCacheTagRegistry,
)
Expand Down Expand Up @@ -142,7 +142,7 @@ describe('CacheTagInvalidator', () => {

describe('getCacheTags', () => {
beforeEach(() => {
cacheTagInvalidator = new CacheTagInvalidator(
cacheTagInvalidator = new InMemoryCacheTagInvalidator(
cacheContext,
mockCacheTagRegistry,
)
Expand Down Expand Up @@ -240,7 +240,7 @@ describe('CacheTagInvalidator', () => {

describe('invalidate with cache tag registry', () => {
beforeEach(() => {
cacheTagInvalidator = new CacheTagInvalidator(
cacheTagInvalidator = new InMemoryCacheTagInvalidator(
cacheContext,
mockCacheTagRegistry,
)
Expand Down Expand Up @@ -321,7 +321,7 @@ describe('CacheTagInvalidator', () => {
// route and component are missing
}

const invalidator = new CacheTagInvalidator(
const invalidator = new InMemoryCacheTagInvalidator(
incompleteCacheContext,
mockCacheTagRegistry,
)
Expand All @@ -336,7 +336,7 @@ describe('CacheTagInvalidator', () => {

describe('invalidate without cache tag registry (fallback)', () => {
beforeEach(() => {
cacheTagInvalidator = new CacheTagInvalidator(cacheContext, null)
cacheTagInvalidator = new InMemoryCacheTagInvalidator(cacheContext, null)
})

it('should invalidate using fallback method', async () => {
Expand Down Expand Up @@ -421,7 +421,7 @@ describe('CacheTagInvalidator', () => {
// other caches are missing
}

const invalidator = new CacheTagInvalidator(incompleteCacheContext, null)
const invalidator = new InMemoryCacheTagInvalidator(incompleteCacheContext, null)
invalidator.add(['tag1'])

const result = await invalidator.invalidate()
Expand All @@ -432,7 +432,7 @@ describe('CacheTagInvalidator', () => {

describe('timeout and state management', () => {
beforeEach(() => {
cacheTagInvalidator = new CacheTagInvalidator(
cacheTagInvalidator = new InMemoryCacheTagInvalidator(
cacheContext,
mockCacheTagRegistry,
)
Expand Down
Loading