github.com/authzed/spicedb@v1.32.1-0.20240520085336-ebda56537386/internal/datastore/proxy/schemacaching/intervaltracker.go (about)

     1  package schemacaching
     2  
     3  import (
     4  	"slices"
     5  	"sync"
     6  	"time"
     7  
     8  	"github.com/authzed/spicedb/pkg/datastore"
     9  )
    10  
    11  // intervalTracker is a specialized type for tracking a value over a set of
    12  // revision-based time intervals.
    13  type intervalTracker[T any] struct {
    14  	// sortedEntries are the entries in the interval tracker, sorted with the *latest* being *first*.
    15  	sortedEntries []intervalTrackerEntry[T]
    16  
    17  	// lock is the sync lock for the tracker.
    18  	lock sync.RWMutex
    19  }
    20  
    21  // intervalTrackerEntry is a single entry in the interval tracker, over a
    22  // period of revisions.
    23  type intervalTrackerEntry[T any] struct {
    24  	// created is the point at which the entry was created.
    25  	created time.Time
    26  
    27  	// value is the value for the tracked interval.
    28  	value T
    29  
    30  	// startingRevision at which the interval begins.
    31  	startingRevision datastore.Revision
    32  
    33  	// endingRevision is the *exclusive* revision at which the interval ends. If not specified,
    34  	// then the interval is open until the last checkpoint revision.
    35  	endingRevisionOrNil datastore.Revision
    36  }
    37  
    38  // newIntervalTracker creates a new interval tracker.
    39  func newIntervalTracker[T any]() *intervalTracker[T] {
    40  	return &intervalTracker[T]{
    41  		sortedEntries: make([]intervalTrackerEntry[T], 0, 1),
    42  	}
    43  }
    44  
    45  // removeStaleIntervals removes all fully-defined intervals that were created at least window-time ago.
    46  // Returns true if *all* intervals were removed, and the tracker is now empty.
    47  func (it *intervalTracker[T]) removeStaleIntervals(window time.Duration) bool {
    48  	threshold := time.Now().Add(-window)
    49  
    50  	it.lock.Lock()
    51  	defer it.lock.Unlock()
    52  
    53  	it.sortedEntries = slices.DeleteFunc(it.sortedEntries, func(entry intervalTrackerEntry[T]) bool {
    54  		// The open-ended entry always remains.
    55  		if entry.endingRevisionOrNil == nil {
    56  			return false
    57  		}
    58  
    59  		return entry.created.Before(threshold)
    60  	})
    61  
    62  	return len(it.sortedEntries) == 0
    63  }
    64  
    65  // lookup performs lookup of the value in the tracker at the specified revision. lastCheckpointRevision is the
    66  // bound to use for the ending revision for the unbounded entry in the tracker (if any).
    67  func (it *intervalTracker[T]) lookup(revision datastore.Revision, lastCheckpointRevision datastore.Revision) (T, bool) {
    68  	it.lock.RLock()
    69  	defer it.lock.RUnlock()
    70  
    71  	// NOTE: The sortedEntries slice is sorted from latest to least recent, which is opposite that expected
    72  	// by BinarySearchFunc, so all the returned values below are "inverted".
    73  	index, ok := slices.BinarySearchFunc(
    74  		it.sortedEntries,
    75  		revision,
    76  		func(entry intervalTrackerEntry[T], rev datastore.Revision) int {
    77  			// If the entry's starting revision exactly matches the revision, then we know we've found
    78  			// the correct entry.
    79  			if entry.startingRevision.Equal(rev) {
    80  				return 0
    81  			}
    82  
    83  			// If the entry starts after the revision, then it precedes the revision in the slice.
    84  			if entry.startingRevision.GreaterThan(rev) {
    85  				return -1
    86  			}
    87  
    88  			// Check if the revision is found within the entry.
    89  			if entry.endingRevisionOrNil != nil {
    90  				// If the revision is less than the ending revision (exclusively), then we've found
    91  				// the correct entry.
    92  				if rev.LessThan(entry.endingRevisionOrNil) {
    93  					return 0
    94  				}
    95  
    96  				// Otherwise, the revision is later that ending the entry, which means the revision
    97  				// precedes the entry in the slice.
    98  				return 1
    99  			}
   100  
   101  			// If the last checkpoint revision is nil, then the entry's ending revision is closed to
   102  			// anything beyond the entry's starting revision.
   103  			endingRevisionInclusive := lastCheckpointRevision
   104  			if lastCheckpointRevision == nil {
   105  				endingRevisionInclusive = entry.startingRevision
   106  			}
   107  
   108  			// If the entry has no ending revision, then the supplied last checkpoint revision is the *inclusive*
   109  			// revision for the ending.
   110  			if rev.LessThan(endingRevisionInclusive) || rev.Equal(endingRevisionInclusive) {
   111  				return 0
   112  			}
   113  
   114  			if rev.GreaterThan(endingRevisionInclusive) {
   115  				return -1
   116  			}
   117  
   118  			return 1
   119  		})
   120  	if !ok {
   121  		return *new(T), false
   122  	}
   123  
   124  	return it.sortedEntries[index].value, true
   125  }
   126  
   127  // add adds an entry into the tracker, indicating it becomes alive at the given revision.
   128  // Returns whether the entry was successfully added. An entry can only be added if it is
   129  // the latest entry: any attempt to add an entry at a revision before the latest found will
   130  // return false and no-op.
   131  func (it *intervalTracker[T]) add(entry T, revision datastore.Revision) bool {
   132  	now := time.Now()
   133  
   134  	it.lock.Lock()
   135  	defer it.lock.Unlock()
   136  
   137  	if len(it.sortedEntries) == 0 {
   138  		it.sortedEntries = append(it.sortedEntries, intervalTrackerEntry[T]{
   139  			created:             now,
   140  			value:               entry,
   141  			startingRevision:    revision,
   142  			endingRevisionOrNil: nil,
   143  		})
   144  		return true
   145  	}
   146  
   147  	if revision.LessThan(it.sortedEntries[0].startingRevision) {
   148  		return false
   149  	}
   150  
   151  	// If given the same revision as the top entry (which can happen from some datastores), ignore.
   152  	if revision.Equal(it.sortedEntries[0].startingRevision) {
   153  		return true
   154  	}
   155  
   156  	it.sortedEntries[0].endingRevisionOrNil = revision
   157  	it.sortedEntries = append([]intervalTrackerEntry[T]{
   158  		{
   159  			created:             now,
   160  			value:               entry,
   161  			startingRevision:    revision,
   162  			endingRevisionOrNil: nil,
   163  		},
   164  	}, it.sortedEntries...)
   165  	return true
   166  }