Skip to content

Commit e532b8b

Browse files
EvanYan1024SurbhiAgarwal1
authored andcommitted
perf(storage): split UnspentTokensIteratorBy into UNION ALL of two index-friendly branches (LFDT-Panurus#1681)
Signed-off-by: Evan <evanyan@sign.global> Co-authored-by: Evan <evanyan@sign.global>
1 parent 5a04326 commit e532b8b

2 files changed

Lines changed: 174 additions & 19 deletions

File tree

token/services/storage/db/sql/common/query_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"testing"
1111

1212
q "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/driver/sql/query"
13+
qcommon "github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/driver/sql/query/common"
1314
"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/driver/sql/query/cond"
1415
"github.com/hyperledger-labs/fabric-smart-client/platform/view/services/storage/driver/sql/sqlite"
1516
"github.com/stretchr/testify/assert"
@@ -74,3 +75,32 @@ func TestDelete_Compile(t *testing.T) {
7475
assert.Equal(t, "DELETE FROM users WHERE id = $1", query)
7576
assert.Equal(t, 1, args[0])
7677
}
78+
79+
// TestUnionAll_Compile verifies that two SELECT queries can be combined into
80+
// a single UNION ALL statement via a shared builder, with placeholder
81+
// numbering continuing across branches and args concatenated in order.
82+
// This is the pattern used by UnspentTokensIteratorBy.
83+
func TestUnionAll_Compile(t *testing.T) {
84+
ci := sqlite.NewConditionInterpreter()
85+
86+
branch1 := q.Select().
87+
FieldsByName("id", "name").
88+
From(q.Table("users")).
89+
Where(cond.Eq("id", 1))
90+
91+
branch2 := q.Select().
92+
FieldsByName("id", "name").
93+
From(q.Table("admins")).
94+
Where(cond.Eq("name", "alice"))
95+
96+
sb := qcommon.NewBuilder()
97+
branch1.FormatTo(ci, sb)
98+
sb.WriteString(" UNION ALL ")
99+
branch2.FormatTo(ci, sb)
100+
query, args := sb.Build()
101+
102+
assert.Equal(t,
103+
"SELECT id, name FROM users WHERE id = $1 UNION ALL SELECT id, name FROM admins WHERE name = $2",
104+
query)
105+
assert.Equal(t, []any{1, "alice"}, args)
106+
}

token/services/storage/db/sql/common/tokens.go

Lines changed: 144 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
148171
func (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

Comments
 (0)