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!