Time-based TTLs with lazy delete

Go's time.Time encodes the unit. time.Now().Add(5 * time.Second) is unambiguous in a way Unix seconds are not. The sidecar approach from Python translates directly.

04-expiry/main.go
go
type Store struct {
    mu      sync.RWMutex
    m       map[string]string
    expires map[string]time.Time
}

func (s *Store) checkExpiredLocked(key string) bool {
    ts, ok := s.expires[key]
    if !ok { return false }
    if time.Now().After(ts) {
        delete(s.m, key); delete(s.expires, key)
        return true
    }
    return false
}

Store now has expires map[string]time.Time. checkExpiredLocked runs inside every read.

04-expiry/main.go
go
func (s *Store) Get(key string) (string, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.checkExpiredLocked(key)
    v, ok := s.m[key]
    return v, ok
}

Get changed since the last step: it now takes the full write lock and runs the expiry check before reading.

Because lazy delete is a write. checkExpiredLocked can mutate the maps if the key is expired. RLock would race against this. The cost: GETs no longer fan out concurrently for keys with TTLs. Production Redis solves this with sharded stores or atomic timestamp reads. The exercises walk through both.

04-expiry/main.go
go
func parseSetOptions(args []string) (key, value string, ttl time.Duration, hasTTL bool, errMsg string) {
    if len(args) < 2 { return "", "", 0, false, "ERR wrong arguments" }
    key, value = args[0], args[1]
    i := 2
    for i < len(args) {
        opt := strings.ToUpper(args[i])
        if opt == "EX" && i+1 < len(args) {
            n, _ := strconv.Atoi(args[i+1])
            ttl = time.Duration(n) * time.Second; hasTTL = true; i += 2
        } else if opt == "PX" && i+1 < len(args) {
            n, _ := strconv.Atoi(args[i+1])
            ttl = time.Duration(n) * time.Millisecond; hasTTL = true; i += 2
        } else { return "", "", 0, false, "ERR option" }
    }
    return
}

Parsing SET ... EX N. Returns a Duration in seconds, applies via SetWithTTL.

04-expiry/main.go
go
func (s *Store) Del(keys ...string) int {
    s.mu.Lock()
    defer s.mu.Unlock()
    n := 0
    for _, k := range keys {
        s.checkExpiredLocked(k)
        if _, ok := s.m[k]; ok {
            delete(s.m, k); delete(s.expires, k); n++
        }
    }
    return n
}

// ... in the dispatch table:
"DEL": func(args []string) []byte {
    if len(args) == 0 { return encodeError("ERR wrong number of arguments for 'del'") }
    return encodeInteger(store.Del(args...))
},
"EXPIRE": func(args []string) []byte {
    if len(args) != 2 { return encodeError("ERR wrong number of arguments for 'expire'") }
    secs, err := strconv.Atoi(args[1])
    if err != nil { return encodeError("ERR value is not an integer") }
    if store.Expire(args[0], time.Duration(secs)*time.Second) { return encodeInteger(1) }
    return encodeInteger(0)
},

DEL and EXPIRE complete the promised command set. Del is variadic and reports how many keys actually existed. EXPIRE attaches a TTL to an existing key, answering 1 on success and 0 if the key is missing.

04-expiry/main.go
go
func (s *Store) TTL(key string) int {
    s.mu.Lock()
    defer s.mu.Unlock()
    if s.checkExpiredLocked(key) { return -2 }
    if _, ok := s.m[key]; !ok { return -2 }
    ts, ok := s.expires[key]
    if !ok { return -1 }
    d := time.Until(ts)
    if d < 0 { return 0 }
    return int(d.Seconds())
}

TTL follows the real Redis return convention: remaining whole seconds if a timer is set, -1 if the key exists with no TTL, -2 if the key is missing or just lazily expired.

Quiz: Quiz

Loading practiceโ€ฆ