feat(dataset): add View.Close and Set.Close for final-value cleanup

Previously only the *replaced* value was closed on swap — the
*currently-held* value leaked on process exit. Adds:

- View.Close() — closes the held value (if it implements io.Closer)
  and clears the pointer; idempotent.
- Set.Close() — joins Close errors across all views in the set.

form2mail defers geoSet.Close() to release the geoip2 file handles on
shutdown.
This commit is contained in:
AJ ONeal 2026-04-20 22:23:32 -06:00
parent 9e46d5ce90
commit 4ab9ffe364
No known key found for this signature in database
2 changed files with 32 additions and 0 deletions

View File

@ -293,6 +293,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
defer func() { _ = geoSet.Close() }()
go blocklistSet.Tick(ctx, refreshInterval, func(err error) {
log.Printf("blocklist refresh: %v", err)
})

View File

@ -16,6 +16,7 @@ package dataset
import (
"context"
"errors"
"io"
"os"
"sync"
@ -137,6 +138,23 @@ func (s *Set) Load(ctx context.Context) error {
return nil
}
// Close closes every view's currently-held value. Call on shutdown to
// release any OS resources held by the final snapshots (file handles,
// network connections). Safe to call on a Set that hasn't been loaded
// or whose views hold pure in-memory values — non-Closer values are
// skipped. Returns joined errors from any Close failures.
func (s *Set) Close() error {
var errs []error
for _, v := range s.views {
if c, ok := v.(io.Closer); ok {
if err := c.Close(); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
// Tick calls Load every interval until ctx is done. Load errors are passed to
// onError (if non-nil) and do not stop the loop; callers choose whether to log,
// count, page, or ignore. Run in a goroutine: `go s.Tick(ctx, d, onError)`.
@ -177,6 +195,19 @@ func (v *View[T]) LoadedAt() time.Time {
return time.Time{}
}
// Close clears the view and closes the currently-held value if it
// implements io.Closer. Idempotent — subsequent calls are no-ops.
func (v *View[T]) Close() error {
prev := v.ptr.Swap(nil)
if prev == nil {
return nil
}
if closer, ok := any(prev).(io.Closer); ok {
return closer.Close()
}
return nil
}
func (v *View[T]) reload(ctx context.Context) error {
t, err := v.loader(ctx)
if err != nil {