panurus

Selector Service

The Selector Service (token/services/selector) picks the unspent tokens (UTXOs) that fund a transaction and holds them under a temporary lock while the transaction is assembled, so that concurrent transactions of the same wallet do not try to spend the same tokens.

Core Responsibilities

The Selector Service is responsible for:

Interaction with TTX and Storage

The Selector Service bridges the gap between the high-level TTX Service and the internal TokenDB.

graph LR
    TTX[TTX Service] --> Selector[Selector Service]
    
    subgraph "Token Fetcher"
        Fetcher[Fetcher Logic]
        Fetcher -->|Cache Hit| Cache[Cache]
        Fetcher -->|Cache Miss| TokenDB[Token Store - TokenDB]
    end
    
    subgraph "Selection Logic"
        Query[Query Spendable Tokens]
        Pick[Take Next Candidate - randomized order]
        Lock[Acquire Temporary Lock]
        Done[Return Locked Tokens]
    end
    
    Selector --> Fetcher
    Selector --> Query
    Query --> Pick
    Pick --> Lock
    Lock -->|locked by another process, or sum still below target| Pick
    Lock -->|requested amount covered| Done

How the components interact:

Key Components

Selector Manager

The SelectorManager is the entry point for obtaining a Selector instance anchored to a specific transaction. It ensures that the selection process is consistent and tied to the lifecycle of a single token request.

Token Selection Algorithm

Selection is a randomized greedy first-fit. It is not configurable, and it is not amount-aware. Selector.selectInternal (token/services/selector/sherdlock/selector.go) does the following:

  1. the candidate tokens of the wallet and token type are enumerated in randomized order,
  2. each candidate is locked as it is encountered — a candidate already locked by another process is skipped; a lock failure wrapping token.SelectorRateLimited is a hard abort (not a skip),
  3. the amounts of the successfully locked tokens are added up, and
  4. the selector returns as soon as the running sum reaches the requested quantity.

A token’s amount therefore only decides when the loop stops, never which candidate is picked. Two consequences worth planning for:

The randomization is deliberate. It is what spreads concurrent selectors of the same wallet across different candidates: walking a fixed order would make every selector contend for the same first tokens, driving up lock failures and, with them, the immediate-retry path that gives up with token.SelectorSufficientButLockedFunds, and beyond it the backoff path that ends in token.SelectorInsufficientFunds.

The shuffle lives in the sherdlock fetcher, not in the selection loop (token/services/selector/sherdlock/fetcher.go): the lazy fetcher wraps the database iterator in collections.NewPermutatedIterator, and the cached fetcher hands out a fresh permutation of the cached slice on every query. The simple driver does not shuffle — it walks the database iterator in the order the token store returns it (token/services/selector/simple/selector.go) — so concurrent selectors under simple are more exposed to colliding on the same leading candidates.

How it works in the flow (see “Selection Logic” subgraph in diagram):

  1. TTX Request: TTX Service requests token selection for a transfer operation
  2. Query Spendable Tokens: Selector queries via Fetcher (Cache Hit → fast path, Cache Miss → Token Store - TokenDB)
  3. Take Next Candidate: Selector takes the next token from the randomized candidate set
  4. Acquire Temporary Lock: The candidate is locked to prevent double-spending (in the TokenLocks table under the sherdlock driver, in memory under simple); on success its amount is added to the running sum, on failure the loop moves to the next candidate
  5. Return or Retry: The selector returns as soon as the sum covers the request; if the candidate set is exhausted while other processes hold locks, it retries in two distinct layers:
    • Immediate-retry layer (sherdlock only): the inner loop refetches — refreshing the sherdlock token cache via the fetcher — up to a hardcoded maxImmediateRetries = 5 times without releasing its already-acquired locks, then gives up with token.SelectorSufficientButLockedFunds. Under simple, there is no equivalent cache layer; the outer retry loop re-queries the query service directly on every attempt.
    • Backoff layer: a configurable numRetries / retryInterval outer loop (the StubbornSelector wrapper in sherdlock; the numRetry / timeout loop in simple) releases locks, sleeps, and re-runs the whole selection from scratch. Exhausting this layer returns token.SelectorInsufficientFunds.

Strategies that are not implemented

Amount-aware strategies — smallest-first, largest-first, First-In-First-Out, or minimizing the number of inputs — are not implemented and cannot be configured. There is no strategy abstraction in the code and no configuration key that selects one. Making selection amount-aware is tracked in issue #2017.

Locking Mechanism

To prevent double-spending before the transaction is committed to the ledger, the Selector Service uses a local TokenLocks table in the Storage Service (see “TokenLocks” box in diagram above).

Lock lifecycle:

  1. Lock Acquisition: When the selector takes a candidate token, it attempts to insert a record in the TokenLocks table.
  2. Concurrency Control: If another concurrent process has already locked that token, the insertion fails, and the selector moves on to the next candidate.
  3. Lock Release: Locks are released either when the transaction reaches finality (success/failure) or when a timeout occurs, ensuring that tokens do not remain permanently inaccessible due to crashed or abandoned transactions.

In-Memory Locker Internals

The simple driver keeps its locks in memory (token/services/selector/simple/inmemory) instead of the TokenLocks table. Its state is sharded per owner (the wallet the tokens are selected for): every owner has its own shard, holding that owner’s locked tokens behind its own mutex, and the shards themselves live in a registry map behind a second mutex. Two owners therefore never serialize against each other, not even while a lock attempt is waiting on a transaction-status lookup.

Two invariants keep the two mutex levels safe:

The background collector (the goroutine that frees locks of finalized transactions) copies a shard’s entries, releases the shard lock, and only then looks the transaction statuses up, so a slow status provider never blocks locking or unlocking. Because the shard is unlocked in between, each entry is re-validated before removal — same transaction ID and same last-access time — and entries that were reclaimed or re-accessed meanwhile are kept.

Token Fetcher and Cache

The selector uses a Token Fetcher to retrieve available tokens from the database. The fetcher uses a Ristretto LRU cache to improve performance by caching token queries (keyed by wallet+currency).

Flow: Selector.Select()Fetcher.UnspentTokensIteratorBy(wallet, currency)Token Iterator

How it works:

  1. Selector requests tokens from Fetcher for a specific wallet and currency
  2. Fetcher checks its cache (keyed by wallet+currency)
  3. If cache is fresh, returns cached tokens immediately (fast path)
  4. If cache is stale, queries database and updates cache
  5. Selector iterates through tokens, attempting to lock each one
  6. If insufficient tokens, selector requests fresh data and retries

Adaptive refresh strategy with two triggers:

Configuration

Configure the selector service in your core.yaml:

token:
  selector:
    driver: sherdlock                    # Selector implementation and locking backend: sherdlock | simple (default: sherdlock)
    numRetries: 3                        # Retry attempts for token selection (default: 3)
    retryInterval: 5s                    # Wait time between retries (default: 5s)
    leaseExpiry: 3m                      # Lock expiration time (default: 3m)
    leaseCleanupTickPeriod: 1m           # Lock cleanup interval (default: 1m)
    fetcherCacheSize: 1000               # Cache size in entries (default: 0 = use fetcher default)
    fetcherCacheRefresh: 30s             # Cache refresh interval (default: 0 = use fetcher default)
    fetcherCacheMaxQueries: 100          # Max queries before cache refresh (default: 0 = use fetcher default)

Driver

driver selects the selector implementation and, with it, the locking backend:

It does not select a selection algorithm: both drivers walk candidates greedily and stop on first cover, but they diverge in several ways beyond the shuffle:

Cache Configuration

The fetcher cache improves performance by caching token queries:

Example: With fetcherCacheSize: 1000, fetcherCacheRefresh: 30s, and fetcherCacheMaxQueries: 100, the cache stores up to 1000 query results, refreshes data every 30 seconds, and forces a refresh after 100 queries.