Skip to content

Cache Helpers & Locks

ICache covers get, set and delete. Everything else redis can do — counters, conditional writes, expiry, distributed locks — comes as functions over that interface.

go
n, err := core.CacheIncr(ctx.Cache(), "otp:"+id, 1, time.Minute)

user, err := core.CacheRemember(ctx.Cache(), "user:"+id, time.Hour,
  func() (*models.User, error) { return loadUser(id) })

err := core.WithLock(ctx.Cache(), "settle:"+id, time.Minute, func() error {
  return settle(ctx, id)
})

Why functions and not methods

ICache is an interface your service may already implement — a fake in its tests, a decorator around the real one. A method added to it would stop every one of those from compiling. A function over the interface costs nothing and breaks nobody.

They work against whatever backend is behind the ICache: redis, the in-memory cache, or anything implementing core.ICacheOps.

Cache-aside: CacheRemember

The shape most cache reads actually want — return what is cached, compute and store it on a miss.

go
func (s userService) Find(id string) (*models.User, core.IError) {
  user, err := core.CacheRemember(s.ctx.Cache(), "user:"+id, time.Hour,
    func() (*models.User, error) {
      return repository.New[models.User](s.ctx).FindOne("id = ?", id)
    })
  if err != nil {
    return nil, s.ctx.NewError(err, errmsgs.NotFound)
  }
  return user, nil
}

A cache that cannot be read is treated as a miss, not a failure: the value is computed and returned. A cache being down should slow a request down, not fail it. A write that does not land is ignored for the same reason — the caller has the value either way.

Invalidate with CacheForget, which likewise ignores an unavailable cache:

go
core.CacheForget(ctx.Cache(), "user:"+id, "user:"+id+":permissions")

Stampede protection: CacheRememberOnce

When a hot key expires, every in-flight request misses at once and they all run the loader together. CacheRememberOnce puts a lock around it so one caller computes and the rest wait:

go
report, err := core.CacheRememberOnce(ctx.Cache(), "report:daily",
  time.Hour,        // how long the value is kept
  2*time.Second,    // how long a loser waits for the winner
  func() (*Report, error) { return buildDailyReport(ctx) })

The callers that lose the race and time out compute it themselves rather than failing — a slow loader degrades into duplicated work, never into an error.

Reach for it when the loader is expensive enough that a stampede would hurt. CacheRemember is cheaper and right for everything else.

Counters and rate limits: CacheIncr

go
attempts, err := core.CacheIncr(ctx.Cache(), "login:"+ip, 1, time.Minute)
if attempts > 5 {
  return ctx.NewError(nil, errmsgs.TooManyRequests)
}

The expiry is applied only when the key is created. A counter over a fixed window keeps the window it started with, rather than having it pushed out by every hit — which is what would make "five attempts a minute" unreachable for a caller that keeps trying.

Conditional writes and expiry

go
won, err := core.CacheSetNX(c, "job:nightly", "taken", time.Hour) // only if unset
exists, err := core.CacheExists(c, key)
ttl, err := core.CacheTTL(c, key)      // -1 no expiry, -2 no key
ok, err := core.CacheExpire(c, key, time.Hour)

Invalidating a group: CacheDelByPrefix

go
deleted, err := core.CacheDelByPrefix(ctx.Cache(), "user:"+id+":")

It scans in batches rather than using KEYS, which blocks the redis server for as long as it takes to walk the whole keyspace — on a production instance that is an outage, not a slow command.

An empty prefix is refused: whatever the caller meant, it was not "delete the entire keyspace".

Distributed locks

A lock held across processes, so only one replica does a thing at a time.

go
err := core.WithLock(ctx.Cache(), "settle:"+orderID, time.Minute, func() error {
  return settle(ctx, orderID)
})
if errors.Is(err, core.ErrLockNotAcquired) {
  return nil // another worker has it
}

WithLock releases the key however fn ends — including a panic. A holder that panics out of the section would otherwise leave the key behind and everybody else waiting on it until it expires.

For finer control:

go
lock, err := core.CacheLock(ctx.Cache(), "settle:"+id, time.Minute)
if errors.Is(err, core.ErrLockNotAcquired) {
  return nil
}
defer lock.Unlock()

// long job: push the expiry out as it goes
if err := lock.Extend(time.Minute); err != nil {
  return err // the lock is gone; stop rather than carry on unprotected
}

LockWait waits for a turn instead of giving up immediately:

go
lock, err := core.LockWait(ctx.Cache(), key, time.Minute, 5*time.Second)

It gives up with an ErrLockNotAcquired error rather than blocking forever, so a jammed lock surfaces as a failed request instead of an exhausted goroutine pool.

What makes the lock safe

It always expires. A holder that crashes releases it by doing nothing, so one bad deploy cannot wedge a queue until somebody clears a key by hand. A ttl of zero means core.DefaultLockTTL (30s) — there is no such thing as a lock without one here.

It only ever releases its own lock. Every holder gets a token, and release is a compare-and-delete. A plain delete would drop a lock that had already expired and been picked up by somebody else, putting two workers in the section that was supposed to have one.

Extend fails once the lock is lost. That is the signal to stop the work it was guarding, not to carry on unprotected.

ErrorMeaning
core.ErrLockNotAcquiredsomebody else holds it — the ordinary outcome
core.ErrLockLostit expired or was taken over; stop the work
core.ErrCacheUnavailablethere is no backend behind this ICache

Compare with errors.Is.

Backends

Redis

The normal one. See Cache (redis).

In-memory

go
cache := core.NewMemoryCache()
defer cache.Close()

A real cache, not a stub: values are encoded the way the redis client encodes them and read back through the same scanner, keys expire, counters are atomic, and locks exclude other goroutines. The same test suite runs against both to keep that true.

What it cannot do is span processes — two replicas each get their own — so it is the wrong choice for anything that has to coordinate across instances. Use it in tests, and in a single-instance service that has no redis to talk to.

Client() returns nil: code reaching for the raw redis client has to cope with that, which is the point.

Disabled

go
cache := core.NewNoopCache()

Stores nothing. Every read misses, every write is dropped. It is what a service with no CACHE_HOST should be given, so the code that caches runs unchanged in a deployment without redis: CacheRemember loads every time, the readiness probe passes, and nothing has to be guarded with a nil check.

Its lock always succeeds and excludes nothing. A cache that stores nothing cannot coordinate, and refusing would stop the work outright — running it unprotected is the trade a deployment without redis is making deliberately.

Per-request contexts

Cache commands run under context.Background() by default. To give one a request's deadline:

go
cache := core.CacheWithContext(ctx.Cache(), c.Request().Context())

Commands on that handle are cancelled when the request is. A cache the framework did not build is returned unchanged — it has its own idea of what a command runs under.

Maintained by Passakon Puttasuwan & Dev Core Team.