Kubernetes Client-Go Cache Mechanism: Indexer and ThreadSafeStore Internals

The client-go caching layer relies heavily on the Indexer and ThreadSafeStore abstractions to maintain a local object store. This architecture minimizes direct API server requests by enabling efficient in-memory lookups and multi-dimensional indexing.

Indexer Interface

The Indexer interface extends the base Store contract by introducing retrieval capabilities based on arbitrary metadata fields.

type Indexer interface {
	Store
	Index(indexName string, obj interface{}) ([]interface{}, error)
	IndexKeys(indexName, indexedValue string) ([]string, error)
	ListIndexFuncValues(indexName string) []string
	ByIndex(indexName, indexedValue string) ([]interface{}, error)
	GetIndexers() Indexers
	AddIndexers(newIndexers Indexers) error
}

The default implementation wraps a thread-safe map and a key extractor function:

type cache struct {
	storage      ThreadSafeStore
	keyExtractor KeyFunc
}

Constructor functions initialize the underlying storage with empty index registries. The Add and Update operations delegate to the keyExtractor to derive a unique identifier before forwarding the object to the thread-safe layer. A standard key generator, MetaNamespaceKeyFunc, concatenates the namespace and name (e.g., default/my-pod), falling back to just the name for cluster-scoped resources.

Similarly, index computation relies on IndexFunc, which maps an object to a slice of strings. The default MetaNamespaceIndexFunc isolates the namesapce field, enabling namespace-based partitioning.

ThreadSafeStore Core

ThreadSafeStore orchestrates concurrent access and maintains the index mappings. Its concrete implementation, threadSafeMap, holds a read-write mutex, a primary object map, and two registry maps:

type threadSafeMap struct {
	mu        sync.RWMutex
	storeData map[string]interface{}
	indexReg  Indexers
	indexMaps Indices
}

The Indexers map associates index names (like "namespace") with their corresponding extraction functions. Indices maintains the actual lookups: map[string]Index, where Index is map[string]sets.String. This structure creates a two-level mapping: index name -> indexed value -> set of object keys.

Write Operations & Index Maintenance

Modifying the cache triggers index synchronization. When Add or Update executes, it acquires an exclusive lock, updates the primary map, and calls syncIndices.

func (m *threadSafeMap) syncIndices(prevObj, currObj interface{}, identifier string) {
	if prevObj != nil {
		m.purgeFromIndices(prevObj, identifier)
	}
	for idxName, extractor := range m.indexReg {
		vals, err := extractor(currObj)
		if err != nil {
			panic(fmt.Sprintf("index calculation failed for key %s: %v", identifier, err))
		}
		idxMap := m.indexMaps[idxName]
		if idxMap == nil {
			idxMap = make(Index)
			m.indexMaps[idxName] = idxMap
		}
		for _, val := range vals {
			keySet := idxMap[val]
			if keySet == nil {
				keySet = sets.String{}
				idxMap[val] = keySet
			}
			keySet.Insert(identifier)
		}
	}
}

Removal operations mirror this flow via purgeFromIndices, iterating through registered extractors and deleting the identifier from the corresponding sets. Empty sets are subsequently cleaned up from the index map.

Query Methods

Retrieval methods operate under read locks to ensure concurrency safety. The Index method computes the index values for a given object and returns all matching items:

func (m *threadSafeMap) Index(idxName string, target interface{}) ([]interface{}, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	extractor, exists := m.indexReg[idxName]
	if !exists {
		return nil, fmt.Errorf("unknown index: %s", idxName)
	}
	computedValues, err := extractor(target)
	if err != nil {
		return nil, err
	}
	idxMap := m.indexMaps[idxName]
	var targetKeys sets.String
	if len(computedValues) == 1 {
		targetKeys = idxMap[computedValues[0]]
	} else {
		targetKeys = sets.String{}
		for _, val := range computedValues {
			for k := range idxMap[val] {
				targetKeys.Insert(k)
			}
		}
	}
	result := make([]interface{}, 0, targetKeys.Len())
	for k := range targetKeys {
		result = append(result, m.storeData[k])
	}
	return result, nil
}

For direct lookups where the indexed value is already known, ByIndex bypasses computation:

func (m *threadSafeMap) ByIndex(idxName, targetValue string) ([]interface{}, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	if _, exists := m.indexReg[idxName]; !exists {
		return nil, fmt.Errorf("unknown index: %s", idxName)
	}
	idxMap := m.indexMaps[idxName]
	keySet := idxMap[targetValue]
	result := make([]interface{}, 0, keySet.Len())
	for k := range keySet {
		result = append(result, m.storeData[k])
	}
	return result, nil
}

When only identifiers are required, IndexKeys returns the string set directly:

func (m *threadSafeMap) IndexKeys(idxName, targetValue string) ([]string, error) {
	m.mu.RLock()
	defer m.mu.RUnlock()
	if _, exists := m.indexReg[idxName]; !exists {
		return nil, fmt.Errorf("unknown index: %s", idxName)
	}
	return m.indexMaps[idxName][targetValue].List(), nil
}

Bulk replacements discard existing state and reconstruct indices from scratch:

func (m *threadSafeMap) Replace(newData map[string]interface{}, version string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.storeData = newData
	m.indexMaps = make(Indices)
	for k, v := range m.storeData {
		m.syncIndices(nil, v, k)
	}
}

The interplay between Indexers (functions) and Indices (mappings) allows the cache to support dynamic, multi-dimensional queries while maintaining O(1) primary key access and thread-safe mutations.

Tags: kubernetes client-go Go Informer Cache

Posted on Sat, 09 May 2026 09:00:54 +0000 by erfg1