Skip to the content.
2024/10/14

Recently, I had some costly over-the-network status check that reduces to a single boolean value. The status takes minutes to switch from one state to the other, but status checks can occur multiple times per second.

Make it work

I wanted to cache the status value for a little while, like this:

var (
    lastCheck time.Time
    lastStatus bool
)

func GetStatus() (bool, error) {
    if time.Now().After(lastCheck + time.Duration(time.Minute)) {
        var error err
        lastStatus, err = costlyStatusCheck()
        if err != nil {
            return false, err
        }
        lastCheck = time.Now()        
    }
    return lastStatus, err
}

Make it nice

That’s the idea, but it’s ugly, error-prone, and not reusable. In fact, it’s easy to make it not just nicer, but thanks to Go templates, we can make it work for any type, not just a bool.

// TTLT is a Time-To-Live Type that, when queried, returns a value if the current time is before expiry
type TTLT[T any] struct {
    expiry time.Time
    ttl    time.Duration
    value  T
}

// New initializes a TTLT
func New[T any](ttl time.Duration) *TTLT[T] {
    return &TTLT[T]{
        ttl: ttl,
    }
}

// GetValue returns value, bool where bool is false if the value is expired
func (t *TTLT[T]) GetValue() (T, bool) {
    return t.value, time.Now().Before(t.expiry)
}

// SetValue sets a value, updates the expiry based on the time-to-live set in New(), and returns the given value
func (t *TTLT[T]) SetValue(value T) T {
    t.value = value
    t.expiry = time.Now().Add(t.ttl)
    return value
}

How are we supposed to use this? The best way to answer that is with a unit test!

Test it

func TestTTLT(t *testing.T) {
    d := time.Duration(100 * time.Millisecond)
    // create a TTLT of type bool with a 100ms expiry
    b := ttlt.New[bool](d)

    // no value has been set, so GetValue() returns "expired"
    _, ok := b.GetValue()
    assert.False(t, ok)

    // Set followed by immediate Get returns the value
    vv := b.SetValue(true)
    assert.Equal(t, true, vv)
    v, ok := b.GetValue()
    assert.True(t, ok)
    assert.True(t, v)

    // after the value has expired, GetValue() returns "expired"
    time.Sleep(200 * time.Millisecond)
    _, ok = b.GetValue()
    assert.False(t, ok)

    // Set followed by immediate Get returns the value
    vv = b.SetValue(false)
    assert.Equal(t, false, vv)
    v, ok = b.GetValue()
    assert.True(t, ok)
    assert.False(t, v)
}

Behavior decision!

What should happen when we call TTLT.GetValue() after New() but before SetValue()? For standard types, this is well-defined: we get the default value. For bool, that’s false, for string it’s "". What makes sense for TTLT?

It’s reasonable to expect a default value, but since the default value of the expiry time.Time is “already expired”, this implementation would always return “expired”.

We could say that New() sets the expiry to Time.Now().Add(ttl), so calling GetValue() immediately after New returns the default value of type T. But then, if we used this in the actual case I wrote the code for, we’d delay fetching the actual status if we get a status query immediately at bootup time. That makes no sense at all!

Ultimately I decided on the former behavior - until we call SetValue(), the expiry member retains its default value and all calls to GetValue() will return “expired”.

Use it

Now I can rewrite the initial code like this:

var (
    statusCache = ttlt.New[bool](time.Duration(time.Minute*2))
)

func GetStatus() (bool, error) {
    if status, ok := statusCache.GetValue(); ok {
        return status, nil
    }

    status, err := costlyStatusCheck()
    if err != nil {
        return false, err
    }

    // ttlt.SetValue() returns the value we just set so we can save a line of code like this
    return statusCache.SetValue(status), nil
}

No explicit management of time variables, narrowed opportunity for copypasta errors later on, and a lot easier to read. Neat!