Skip to main content
To manage caches effectively, you need to invalidate them at appropriate times. Sonamu provides various invalidation methods.

Why Invalidation is Needed

Caching improves performance, but can return stale data.
// 1. Get post (cached)
const post = await postModel.findById(1);
// { id: 1, title: "Original Title", views: 100 }

// 2. Update post
await postModel.update(1, { title: "Updated Title" });

// 3. Get again
const updatedPost = await postModel.findById(1);
// Still { id: 1, title: "Original Title", views: 100 } ❌
Solution: Invalidate related cache when data changes

Invalidation Methods

1. Delete Specific Key

The most basic method.
class PostModelClass extends BaseModel {
  @cache({ ttl: "10m" })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }

  @api()
  async update(id: number, data: Partial<PostSave>) {
    const result = await this.updateOne(['id', id], data);

    // Delete specific key
    await Sonamu.cache.delete({ key: `Post.findById:${id}` });

    return result;
  }
}
Problem: Must know the exact key and delete all related caches individually Using tags, you can delete related caches as a group.
class PostModelClass extends BaseModel {
  // Assign tags
  @cache({ ttl: "10m", tags: ["post"] })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }

  @cache({ ttl: "5m", tags: ["post", "list"] })
  @api()
  async findAll() {
    return this.findMany({});
  }

  @api()
  async update(id: number, data: Partial<PostSave>) {
    const result = await this.updateOne(['id', id], data);

    // Delete all caches with "post" tag
    await Sonamu.cache.deleteByTag({ tags: ["post"] });

    return result;
  }
}
Result: Both findById() and findAll() caches are deleted

3. Delete Multiple Keys

await Sonamu.cache.deleteMany({
  keys: ["user:1", "user:2", "user:3"]
});

4. Clear All Cache

// Delete all cache
await Sonamu.cache.clear();
Caution: Use carefully in production

Tag Strategies

Hierarchical Tag Design

class ProductModelClass extends BaseModel {
  // Product detail (most specific)
  @cache({ ttl: "1h", tags: ["product", "product:detail"] })
  @api()
  async findById(id: number) {
    return this.findOne(['id', id]);
  }

  // Product list
  @cache({ ttl: "30m", tags: ["product", "product:list"] })
  @api()
  async findAll() {
    return this.findMany({});
  }

  // Category list
  @cache({ ttl: "30m", tags: ["product", "product:list", "product:category"] })
  @api()
  async findByCategory(categoryId: number) {
    return this.findMany({ where: [['category_id', categoryId]] });
  }

  // Popular products
  @cache({ ttl: "1h", tags: ["product", "product:popular"] })
  @api()
  async findPopular() {
    return this.findMany({
      where: [['view_count', '>', 1000]],
      order: [['view_count', 'desc']],
      num: 10
    });
  }
}

Selective Invalidation

class ProductModelClass extends BaseModel {
  @api()
  async create(data: ProductSave) {
    const result = await this.saveOne(data);

    // Invalidate only list (detail remains)
    await Sonamu.cache.deleteByTag({ tags: ["product:list"] });

    return result;
  }

  @api()
  async update(id: number, data: Partial<ProductSave>) {
    const result = await this.updateOne(['id', id], data);

    // Invalidate detail + list
    await Sonamu.cache.deleteByTag({ tags: ["product:detail", "product:list"] });

    return result;
  }

  @api()
  async updateViewCount(id: number) {
    const result = await this.updateOne(['id', id], {
      view_count: this.raw('view_count + 1')
    });

    // View count change only invalidates popular products cache
    await Sonamu.cache.deleteByTag({ tags: ["product:popular"] });

    return result;
  }

  @api()
  async deleteProduct(id: number) {
    const result = await this.deleteOne(['id', id]);

    // Invalidate all product-related cache
    await Sonamu.cache.deleteByTag({ tags: ["product"] });

    return result;
  }
}

Dynamic Tags

Dynamically create tags that only affect specific entities.
class CommentModelClass extends BaseModel {
  @cache({
    key: (postId: number) => `comments:post:${postId}`,
    ttl: "10m",
    tags: (postId: number) => [`comment`, `comment:post:${postId}`]
  })
  @api()
  async findByPostId(postId: number) {
    return this.findMany({ where: [['post_id', postId]] });
  }

  @api()
  async create(data: CommentSave) {
    const result = await this.saveOne(data);

    // Invalidate only that post's comment cache
    await Sonamu.cache.deleteByTag({
      tags: [`comment:post:${data.post_id}`]
    });

    return result;
  }
}
Note: BentoCache’s @cache decorator only supports static tags. For dynamic tags, you need to manipulate the cache directly:
@api()
async findByPostId(postId: number) {
  const cacheKey = `comments:post:${postId}`;
  const cacheTags = [`comment`, `comment:post:${postId}`];

  return Sonamu.cache.getOrSet({
    key: cacheKey,
    ttl: "10m",
    tags: cacheTags,
    factory: () => this.findMany({ where: [['post_id', postId]] })
  });
}

Practical Examples

1. Post + Comment Invalidation

class PostModelClass extends BaseModel {
  @cache({ ttl: "10m", tags: ["post"] })
  @api()
  async findById(id: number) {
    const post = await this.findOne(['id', id]);
    const comments = await commentModel.findByPostId(id);
    return { ...post, comments };
  }

  @cache({ ttl: "5m", tags: ["post", "list"] })
  @api()
  async findAll() {
    return this.findMany({});
  }
}

class CommentModelClass extends BaseModel {
  @cache({ ttl: "10m", tags: ["comment"] })
  @api()
  async findByPostId(postId: number) {
    return this.findMany({ where: [['post_id', postId]] });
  }

  @api()
  async create(data: CommentSave) {
    const result = await this.saveOne(data);

    // On comment creation: Invalidate comment cache + post cache
    await Sonamu.cache.deleteByTag({ tags: ["comment", "post"] });

    return result;
  }
}

2. User + Permission Invalidation

class UserModelClass extends BaseModel {
  @cache({ ttl: "1h", tags: ["user"] })
  @api()
  async findById(id: number) {
    const user = await this.findOne(['id', id]);
    const roles = await userRoleModel.findByUserId(id);
    return { ...user, roles };
  }
}

class UserRoleModelClass extends BaseModel {
  @cache({ ttl: "1h", tags: ["user", "role"] })
  @api()
  async findByUserId(userId: number) {
    return this.findMany({ where: [['user_id', userId]] });
  }

  @api()
  async assign(userId: number, roleId: number) {
    const result = await this.saveOne({ user_id: userId, role_id: roleId });

    // Invalidate entire user cache (includes permission info)
    await Sonamu.cache.deleteByTag({ tags: ["user"] });

    return result;
  }
}

3. Category Hierarchy

class CategoryModelClass extends BaseModel {
  @cache({ ttl: "1h", tags: ["category", "category:tree"] })
  @api()
  async getTree() {
    return this.buildTree(await this.findMany({}));
  }

  @cache({ ttl: "30m", tags: ["category", "category:products"] })
  @api()
  async getWithProducts(id: number) {
    const category = await this.findOne(['id', id]);
    const products = await productModel.findByCategory(id);
    return { ...category, products };
  }

  @api()
  async update(id: number, data: Partial<CategorySave>) {
    const result = await this.updateOne(['id', id], data);

    // Invalidate only category tree
    await Sonamu.cache.deleteByTag({ tags: ["category:tree"] });

    return result;
  }
}

class ProductModelClass extends BaseModel {
  @api()
  async updateCategory(productId: number, categoryId: number) {
    const result = await this.updateOne(['id', productId], { category_id: categoryId });

    // Invalidate category-product connection cache
    await Sonamu.cache.deleteByTag({ tags: ["category:products", "product"] });

    return result;
  }
}

4. Aggregate Data Invalidation

class OrderModelClass extends BaseModel {
  @cache({ ttl: "10m", tags: ["order", "order:stats"] })
  @api()
  async getStats() {
    const [total, pending, completed] = await Promise.all([
      this.count(),
      this.count({ where: [['status', 'pending']] }),
      this.count({ where: [['status', 'completed']] }),
    ]);
    return { total, pending, completed };
  }

  @api()
  async updateStatus(id: number, status: string) {
    const result = await this.updateOne(['id', id], { status });

    // Invalidate stats cache
    await Sonamu.cache.deleteByTag({ tags: ["order:stats"] });

    return result;
  }
}

Isolation with Namespace

Using Namespace, you can isolate cache per user, per tenant.

Per-User Cache

class UserDataModelClass extends BaseModel {
  @api()
  async getMyData(ctx: Context) {
    const userId = ctx.user.id;
    const userCache = Sonamu.cache.namespace(`user:${userId}`);

    return userCache.getOrSet({
      key: "my-data",
      ttl: "10m",
      factory: async () => {
        return this.findMany({ where: [['user_id', userId]] });
      }
    });
  }

  @api()
  async updateMyData(ctx: Context, data: any) {
    const userId = ctx.user.id;
    const result = await this.saveOne({ ...data, user_id: userId });

    // Invalidate only that user's cache
    const userCache = Sonamu.cache.namespace(`user:${userId}`);
    await userCache.clear();

    return result;
  }
}

Multi-Tenant

class TenantDataModelClass extends BaseModel {
  @api()
  async getData(tenantId: number) {
    const tenantCache = Sonamu.cache.namespace(`tenant:${tenantId}`);

    return tenantCache.getOrSet({
      key: "data",
      ttl: "1h",
      tags: ["tenant-data"],
      factory: async () => {
        return this.findMany({ where: [['tenant_id', tenantId]] });
      }
    });
  }

  @api()
  async clearTenantCache(tenantId: number) {
    const tenantCache = Sonamu.cache.namespace(`tenant:${tenantId}`);

    // Delete all cache for specific tenant
    await tenantCache.clear();
  }
}

Invalidation Strategies

1. Eager Invalidation (Immediate Invalidation)

Delete cache immediately when data changes.
@api()
async update(id: number, data: Partial<PostSave>) {
  const result = await this.updateOne(['id', id], data);

  // Immediate invalidation
  await Sonamu.cache.deleteByTag({ tags: ["post"] });

  return result;
}
Advantages:
  • Simple and clear
  • No stale data
Disadvantages:
  • Reduced hit rate due to frequent cache deletion

2. Lazy Invalidation (Delayed Invalidation)

Rely on TTL for automatic expiration.
@cache({ ttl: "5m", tags: ["post"] })  // Auto-expires after 5 minutes
@api()
async findById(id: number) {
  return this.findOne(['id', id]);
}

@api()
async update(id: number, data: Partial<PostSave>) {
  const result = await this.updateOne(['id', id], data);

  // No invalidation (auto-expires after 5 minutes)

  return result;
}
Advantages:
  • High cache hit rate
  • Simple code
Disadvantages:
  • Potentially stale data for up to 5 minutes

3. Hybrid

Immediate invalidation for important changes, TTL for minor changes:
@api()
async update(id: number, data: Partial<PostSave>) {
  const result = await this.updateOne(['id', id], data);

  // Immediate invalidation only for title/content changes
  if (data.title || data.content) {
    await Sonamu.cache.deleteByTag({ tags: ["post"] });
  }
  // View count etc. rely on TTL

  return result;
}

4. Write-Through (Update on Write)

Update cache instead of deleting.
@api()
async update(id: number, data: Partial<PostSave>) {
  const result = await this.updateOne(['id', id], data);

  // Update cache
  const cacheKey = `Post.findById:${id}`;
  await Sonamu.cache.set({
    key: cacheKey,
    value: result,
    ttl: "10m",
    tags: ["post"]
  });

  return result;
}
Advantages:
  • Cache always up-to-date
  • High hit rate
Disadvantages:
  • Complex cache key management
  • Write performance degradation

Invalidation in Distributed Environment

Synchronization with Bus

When you have multiple servers, using Bus propagates invalidation to all servers.
// sonamu.config.ts
import Redis from "ioredis";

const redis = new Redis({ host: "localhost", port: 6379 });

export const config: SonamuConfig = {
  server: {
    cache: {
      default: "main",
      stores: {
        main: store()
          .useL1Layer(drivers.memory({ maxSize: "200mb" }))
          .useL2Layer(drivers.redis({ connection: redis }))
          .useBus(drivers.redisBus({ connection: redis })),  // Add Bus
      },
    },
  },
};
How It Works:
Server 1: deleteByTag("product")

Bus: Publish "product tag invalidation" message

Server 2, 3, 4: Receive Bus message → Delete L1 cache

Using Without Bus

Without Bus, each server’s L1 is independent:
// Configuration without Bus
store()
  .useL1Layer(drivers.memory({ maxSize: "200mb" }))
  .useL2Layer(drivers.redis({ connection: redis }))
// No Bus
Problem:
Server 1: deleteByTag("product") → L1/L2 deleted
Server 2: Still has stale cache in L1 ❌
Solutions:
  1. Add Bus (recommended)
  2. Use only L2 without L1 (slower)
  3. Use short TTL (accept stale data)

Monitoring

Check Cache Key

// Check if specific key exists
const exists = await Sonamu.cache.has({ key: "post:1" });
console.log(exists);  // true or false

// Get value
const value = await Sonamu.cache.get({ key: "post:1" });
console.log(value);

Check Before Tag Invalidation

You cannot check affected keys before invalidation, but consistent tag usage makes it predictable.
// Tag naming convention
const tags = {
  post: ["post"],                    // All posts
  postDetail: ["post", "detail"],    // Post detail
  postList: ["post", "list"],        // Post list
  comment: ["comment"],              // All comments
};

// Clear intent during invalidation
await Sonamu.cache.deleteByTag({ tags: ["post", "list"] });  // Lists only
await Sonamu.cache.deleteByTag({ tags: ["post"] });          // All posts

Cautions

Cache Invalidation Cautions:
  1. Avoid Excessive Invalidation: Too frequent invalidation reduces cache effectiveness
    // ❌ Bad example: Delete all cache on every view count increment
    async incrementViewCount(id: number) {
      await this.updateOne(['id', id], { views: this.raw('views + 1') });
      await Sonamu.cache.deleteByTag({ tags: ["post"] });  // Too excessive
    }
    
    // ✅ Good example: View count relies on TTL
    async incrementViewCount(id: number) {
      await this.updateOne(['id', id], { views: this.raw('views + 1') });
      // No invalidation, auto-expires via TTL
    }
    
  2. Maintain Tag Consistency: Consistent tag usage ensures accurate invalidation
    // ❌ Inconsistent
    @cache({ tags: ["Post"] })  // Uppercase
    @cache({ tags: ["post"] })  // Lowercase
    
    // ✅ Consistent
    @cache({ tags: ["post"] })
    @cache({ tags: ["post", "list"] })
    
  3. Prevent Circular Invalidation: If A invalidates B and B invalidates A, infinite loop occurs
    // ❌ Dangerous: Circular invalidation
    class AModel {
      @api()
      async update() {
        // ...
        await Sonamu.cache.deleteByTag({ tags: ["b"] });
      }
    }
    
    class BModel {
      @api()
      async update() {
        // ...
        await Sonamu.cache.deleteByTag({ tags: ["a"] });
      }
    }
    
  4. Bus Required in Distributed Environment: Operating multiple servers without Bus causes inconsistency

Next Steps