@@ -143,34 +143,159 @@ func (db *TokenStore) UnspentTokensIterator(ctx context.Context) (tdriver.Unspen
143143 return db .UnspentTokensIteratorBy (ctx , "" , "" )
144144}
145145
146- // UnspentTokensIteratorBy returns an iterator of unspent tokens owned by the passed id and whose type is the passed on.
147- // The token type can be empty. In that case, tokens of any type are returned.
146+ // UnspentTokensIteratorBy returns an iterator of unspent tokens owned by the
147+ // passed wallet id and of the passed type. Empty tokenType returns all types.
148+ //
149+ // Implemented as a single SQL with two UNION ALL branches so each side can
150+ // use its own index, instead of a cross-table OR predicate that PostgreSQL
151+ // cannot resolve with the partial index on (owner_wallet_id, token_type)
152+ // WHERE is_deleted=false AND owner=true:
153+ //
154+ // 1. tokens directly owned: filters tokens.owner_wallet_id, hits the partial
155+ // index in microseconds. Joins ownership by primary key to preserve the
156+ // pre-PR semantic that a token must have at least one ownership row to
157+ // be visible (StoreToken can persist a tokens row without an ownership
158+ // row when the owners slice is empty, and the original INNER JOIN
159+ // intentionally excluded those).
160+ // 2. tokens reachable via the ownership-delegation table: joins ownership
161+ // -> tokens by primary key. Returns zero rows when delegation is not
162+ // configured, at which point that branch is essentially free.
163+ //
164+ // Both branches share a single SQL statement and therefore a single
165+ // connection from the pool, which avoids the deadlock that would arise if
166+ // two concurrent QueryContexts each tried to acquire a second connection.
167+ // PostgreSQL 9.6+ may also execute the branches in parallel via parallel
168+ // append. UNION ALL is used (not UNION) to skip the per-row sort/hash
169+ // dedup pass; duplicates between the two branches (and within branch 1 when
170+ // a token has multiple ownership rows) are filtered at the iterator layer.
148171func (db * TokenStore ) UnspentTokensIteratorBy (ctx context.Context , walletID string , tokenType token.Type ) (tdriver.UnspentTokensIterator , error ) {
149- tokenTable , ownershipTable := q .Table (db .table .Tokens ), q .Table (db .table .Ownership )
150- query , args := q .Select ().
172+ tokenTable := q .Table (db .table .Tokens )
173+ ownershipTable := q .Table (db .table .Ownership )
174+ joinCond := cond .And (
175+ cond .Cmp (tokenTable .Field ("tx_id" ), "=" , ownershipTable .Field ("tx_id" )),
176+ cond .Cmp (tokenTable .Field ("idx" ), "=" , ownershipTable .Field ("idx" )),
177+ )
178+
179+ // branch 1: filter by tokens.owner_wallet_id only (planner can pick the
180+ // partial index on tokens), but JOIN ownership so a tokens row without
181+ // any ownership row is excluded — matching the pre-PR INNER JOIN.
182+ branch1Conds := []cond.Condition {
183+ cond .Eq ("owner" , true ),
184+ cond .Eq ("is_deleted" , false ),
185+ }
186+ if len (walletID ) > 0 {
187+ branch1Conds = append (branch1Conds , cond .Eq ("owner_wallet_id" , walletID ))
188+ }
189+ if len (tokenType ) > 0 {
190+ branch1Conds = append (branch1Conds , cond .Eq ("token_type" , tokenType ))
191+ }
192+ // Both branches select ownership.wallet_id as the trailing column. It
193+ // isn't part of the iterator output but is used as the dedup key so a
194+ // (token, ownership) pair appearing in both branches (when walletID
195+ // matches both tokens.owner_wallet_id and ownership.wallet_id of the
196+ // same row) is emitted once. Pre-PR semantics yield one row per
197+ // matching (token, ownership) pair, including duplicates per token
198+ // when multiple ownership rows match (e.g. shared-ownership tokens).
199+ branch1 := q .Select ().
151200 Fields (
152- tokenTable .Field ("tx_id" ), tokenTable .Field ("idx" ), common3 .FieldName ("owner_raw" ),
153- common3 .FieldName ("token_type" ), common3 .FieldName ("quantity" ),
201+ tokenTable .Field ("tx_id" ), tokenTable .Field ("idx" ),
202+ common3 .FieldName ("owner_raw" ), common3 .FieldName ("token_type" ), common3 .FieldName ("quantity" ),
203+ ownershipTable .Field ("wallet_id" ),
154204 ).
155- From (tokenTable .Join (ownershipTable , cond .And (
156- cond .Cmp (tokenTable .Field ("tx_id" ), "=" , ownershipTable .Field ("tx_id" )),
157- cond .Cmp (tokenTable .Field ("idx" ), "=" , ownershipTable .Field ("idx" ))),
158- )).
159- Where (HasTokenDetails (driver.QueryTokenDetailsParams {
160- WalletID : walletID ,
161- TokenType : tokenType ,
162- }, tokenTable )).
163- Format (db .ci )
205+ From (tokenTable .Join (ownershipTable , joinCond )).
206+ Where (cond .And (branch1Conds ... ))
207+
208+ // branch 2: filter by ownership.wallet_id. `wallet_id` is the unique
209+ // unqualified column on the ownership side of the join (tokens has
210+ // owner_wallet_id, not wallet_id), so the condition resolves
211+ // unambiguously to ownership.wallet_id.
212+ branch2Conds := []cond.Condition {
213+ cond .Eq ("owner" , true ),
214+ cond .Eq ("is_deleted" , false ),
215+ cond .Eq ("wallet_id" , walletID ),
216+ }
217+ if len (tokenType ) > 0 {
218+ branch2Conds = append (branch2Conds , cond .Eq ("token_type" , tokenType ))
219+ }
220+ branch2 := q .Select ().
221+ Fields (
222+ tokenTable .Field ("tx_id" ), tokenTable .Field ("idx" ),
223+ common3 .FieldName ("owner_raw" ), common3 .FieldName ("token_type" ), common3 .FieldName ("quantity" ),
224+ ownershipTable .Field ("wallet_id" ),
225+ ).
226+ From (tokenTable .Join (ownershipTable , joinCond )).
227+ Where (cond .And (branch2Conds ... ))
228+
229+ // Combine both branches into one statement using a shared builder so
230+ // the placeholder counter (`$1`, `$2`, ...) keeps incrementing across
231+ // branches; each branch's args are appended in order. SQLite rejects
232+ // parenthesised SELECT operands around UNION, so emit unwrapped form
233+ // (PostgreSQL accepts both). Neither branch has ORDER BY / LIMIT, so
234+ // dropping the parens does not change binding.
235+ sb := common3 .NewBuilder ()
236+ branch1 .FormatTo (db .ci , sb )
237+ sb .WriteString (" UNION ALL " )
238+ branch2 .FormatTo (db .ci , sb )
239+ query , args := sb .Build ()
164240
165241 logging .Debug (logger , query , args )
166242 rows , err := db .readDB .QueryContext (ctx , query , args ... )
167243 if err != nil {
168- return nil , err
244+ return nil , errors . Wrapf ( err , "error querying unspent tokens for wallet [%s] type [%s]" , walletID , tokenType )
169245 }
170246
171- return common .NewIterator (rows , func (r * token.UnspentToken ) error {
172- return rows .Scan (& r .Id .TxId , & r .Id .Index , & r .Owner , & r .Type , & r .Quantity )
173- }), nil
247+ return & dedupedTokenRowsIterator {
248+ rows : rows ,
249+ seen : make (map [string ]struct {}),
250+ }, nil
251+ }
252+
253+ // dedupedTokenRowsIterator yields one (token, ownership.wallet_id) pair per
254+ // (tx_id, idx, ownership.wallet_id) tuple. UNION ALL between branches 1 and
255+ // 2 can emit the same (token, ownership) row twice when walletID matches
256+ // both tokens.owner_wallet_id and ownership.wallet_id of the same row;
257+ // pre-PR behaviour was a single row in that case. Distinct (token,
258+ // ownership) pairs (e.g. shared-ownership tokens with multiple wallets in
259+ // the ownership table) are preserved — they have different keys.
260+ //
261+ // The trailing wallet_id column is read for the dedup key only and is not
262+ // surfaced on token.UnspentToken. ownership.wallet_id can be NULL when the
263+ // LEFT JOIN finds no matching ownership row (a tokens row with
264+ // owner_wallet_id set but no entry in the ownership table — StoreToken
265+ // allows that when owners is empty), so it is scanned as sql.NullString.
266+ type dedupedTokenRowsIterator struct {
267+ rows * sql.Rows
268+ seen map [string ]struct {}
269+ }
270+
271+ func (it * dedupedTokenRowsIterator ) Close () {
272+ _ = it .rows .Close ()
273+ }
274+
275+ func (it * dedupedTokenRowsIterator ) Next () (* token.UnspentToken , error ) {
276+ for it .rows .Next () {
277+ var t token.UnspentToken
278+ var ownerID sql.NullString
279+ if err := it .rows .Scan (& t .Id .TxId , & t .Id .Index , & t .Owner , & t .Type , & t .Quantity , & ownerID ); err != nil {
280+ return nil , err
281+ }
282+ // "\x00" prefix on a Valid wallet_id can never collide with the
283+ // empty-string value used for NULL because the prefix is reserved
284+ // here; without it, NULL and "" would share a key.
285+ var ownerKey string
286+ if ownerID .Valid {
287+ ownerKey = "\x00 " + ownerID .String
288+ }
289+ key := fmt .Sprintf ("%s:%d:%s" , t .Id .TxId , t .Id .Index , ownerKey )
290+ if _ , dup := it .seen [key ]; dup {
291+ continue
292+ }
293+ it .seen [key ] = struct {}{}
294+
295+ return & t , nil
296+ }
297+
298+ return nil , nil
174299}
175300
176301// SpendableTokensIteratorBy returns the minimum information about the tokens needed for the selector
0 commit comments