Skip to content

Commit dc383fd

Browse files
author
Hayim.Shaul@ibm.com
committed
fix(tokens): publish token events only after commit
Token add and delete events were published while the database transaction that produced them was still open, so a subscriber could observe a token that was later rolled back and never persisted. Buffer the events on DBTransaction and publish them from Commit, once the underlying commit succeeded, or from FlushEvents when the transaction is owned by the caller. Rollback discards them. Service.AppendValid applies a request to a transaction it does not own, so it now returns a PostCommit function that the owner of the transaction invokes once its commit succeeded. The finality listener calls it after committing and before waking the finality waiters, so a confirmed transaction is never observed with its token events still pending. Signed-off-by: Hayim.Shaul@ibm.com <hayimsha@fhe03.vpc.cloud9.ibm.com>
1 parent 0dd324a commit dc383fd

9 files changed

Lines changed: 569 additions & 41 deletions

File tree

docs/services/tokens.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The internal [Service](../../token/services/tokens/tokens.go) is responsible for
99
### Core Responsibilities
1010
* **State Management**: Updating the local `TokenDB` to reflect the ledger's state (marking tokens as spendable, pending, or deleted). See [storage.go](../../token/services/tokens/storage.go).
1111
* **Typed Token Support**: Providing a unified way to handle multiple cryptographic token formats (e.g., cleartext vs. commitments) simultaneously via the [TypedToken](../../token/services/tokens/typed.go) structure.
12-
* **Lifecycle Monitoring**: Notifying local listeners (via `events.Publisher`) when tokens are added or removed.
12+
* **Lifecycle Monitoring**: Notifying local listeners (via `events.Publisher`) when tokens are added or removed, once the change has been committed. See [Event Publication and Transaction Boundaries](#event-publication-and-transaction-boundaries).
1313
* **Consistency**: Identifying and removing stale unspent tokens by cross-referencing local storage with the ledger via the [Network Service](../../token/services/network/network.go) (see `PruneInvalidUnspentTokens`).
1414

1515
### Marking Spent Tokens
@@ -21,6 +21,54 @@ event is published for it. A failure reported by the underlying store is always
2121
regardless of whether the token was found locally, so that a transaction is never recorded
2222
as processed while its spends were not applied.
2323

24+
### Event Publication and Transaction Boundaries
25+
26+
The `store-token` (`tokens.AddToken`) and `delete-token` (`tokens.DeleteToken`) events are
27+
published **only after the database transaction that produced them has been committed**. A
28+
subscriber therefore never observes a token that is later rolled back and never persisted.
29+
30+
`DBTransaction.AppendToken` and `DBTransaction.DeleteToken` record their events on the
31+
transaction — `DBTransaction.Notify` buffers, it does not publish — and:
32+
33+
* `DBTransaction.Commit(ctx)` publishes them, and only if the commit succeeded. This covers
34+
the transactions the service owns, obtained from `DBStorage.NewTransaction`.
35+
* `DBTransaction.Rollback` discards them.
36+
* `DBTransaction.FlushEvents(ctx)` publishes them explicitly. It is used when the transaction
37+
is owned by the caller (`DBStorage.ContinueTransaction`), since the service is then not the
38+
one that decides whether the transaction is committed.
39+
40+
`Service.AppendValid` is the continued-transaction case: it applies a token request to a
41+
transaction owned by the caller, and returns a `PostCommit` function alongside the error. The
42+
caller must invoke it after its own commit succeeded, and must not invoke it when it rolls
43+
back:
44+
45+
```go
46+
tx, err := ttxDB.NewTransaction()
47+
if err != nil {
48+
return errors.Wrapf(err, "failed creating new transaction [%s]", txID)
49+
}
50+
defer func() {
51+
if tx != nil {
52+
tx.Rollback()
53+
}
54+
}()
55+
56+
publishTokenEvents, err := tokens.AppendValid(ctx, tx, token.RequestAnchor(txID), tr)
57+
if err != nil {
58+
return errors.Wrapf(err, "failed to append valid token request [%s]", txID)
59+
}
60+
if err := tx.Commit(); err != nil {
61+
return errors.Wrapf(err, "failed commit [%s]", txID)
62+
}
63+
tx = nil
64+
65+
// the tokens are durably stored, so the events may now be observed
66+
publishTokenEvents(ctx)
67+
```
68+
69+
`PostCommit` is never nil, so it can be called unconditionally on the success path, and
70+
calling it more than once publishes nothing further. The in-repo caller is the finality
71+
listener, see [finality/listener.go](../../token/services/ttx/finality/listener.go).
2472

2573
## Token Representations
2674

token/services/tokens/storage.go

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,22 @@ type TokenToAppend struct {
116116
}
117117

118118
// DBTransaction encapsulates a single atomic update to the token database.
119+
//
120+
// Events recorded while the transaction is open are buffered and published only
121+
// once the transaction has been committed, so that a subscriber never observes a
122+
// token that is later rolled back. A DBTransaction is not safe for concurrent
123+
// use, like the underlying database transaction it wraps.
119124
type DBTransaction struct {
120125
// Notifier is used to publish events upon successful deletion or addition.
121126
Notifier events.Publisher
122127
// Tx is the underlying database transaction.
123128
Tx *tokendb.Transaction
124129
// TMSID is the TMS identifier for the transaction.
125130
TMSID token.TMSID
131+
132+
// pending holds the events recorded so far, in the order they were recorded.
133+
// They are published by FlushEvents and discarded by Rollback.
134+
pending []*TokenProcessorEvent
126135
}
127136

128137
// NewTransaction creates a new transaction wrapper.
@@ -134,7 +143,9 @@ func NewTransaction(notifier events.Publisher, tx *tokendb.Transaction, tmsID to
134143
}, nil
135144
}
136145

137-
// DeleteToken removes a single token from the database and notifies listeners.
146+
// DeleteToken removes a single token from the database and records a delete-token
147+
// event for each of its owners. The events are published only after the transaction
148+
// commits, see Notify and FlushEvents.
138149
//
139150
// Delete is idempotent: marking an unknown token as spent is not an error, so a
140151
// failure returned by Delete always signals a real storage failure and is
@@ -176,7 +187,9 @@ func (t *DBTransaction) DeleteTokens(ctx context.Context, deletedBy string, ids
176187
return nil
177188
}
178189

179-
// AppendToken records a new token in the database and notifies listeners.
190+
// AppendToken records a new token in the database and records an add-token event for
191+
// each of its owners. The events are published only after the transaction commits,
192+
// see Notify and FlushEvents.
180193
func (t *DBTransaction) AppendToken(ctx context.Context, tta TokenToAppend) error {
181194
q, err := token2.ToQuantity(tta.Tok.Quantity, tta.Precision)
182195
if err != nil {
@@ -228,7 +241,11 @@ func (t *DBTransaction) AppendToken(ctx context.Context, tta TokenToAppend) erro
228241
return nil
229242
}
230243

231-
// Notify publishes a token-related event to the system's notification bus.
244+
// Notify records a token-related event for publication on the system's notification
245+
// bus. The event is not published here: it is buffered until the transaction that
246+
// produced it has been committed, and then published by FlushEvents. Publishing
247+
// inside the open transaction would let subscribers observe tokens that are never
248+
// persisted.
232249
func (t *DBTransaction) Notify(ctx context.Context, topic string, tmsID token.TMSID, walletID string, tokenType token2.Type, txID string, index uint64) {
233250
if t.Notifier == nil {
234251
logger.WarnfContext(ctx, "cannot notify others!")
@@ -244,18 +261,45 @@ func (t *DBTransaction) Notify(ctx context.Context, topic string, tmsID token.TM
244261
Index: index,
245262
})
246263

247-
logger.DebugfContext(ctx, "publish new event %v", e)
248-
t.Notifier.Publish(e)
264+
logger.DebugfContext(ctx, "record new event %v", e)
265+
t.pending = append(t.pending, e)
266+
}
267+
268+
// FlushEvents publishes the events recorded so far, in the order they were recorded,
269+
// and empties the buffer.
270+
//
271+
// It must be called only after the transaction that produced the events has been
272+
// successfully committed. Commit does this for transactions owned by this type; when
273+
// the transaction is owned by the caller (see DBStorage.ContinueTransaction), the
274+
// caller is responsible for calling FlushEvents after its own commit succeeds.
275+
// Calling it more than once is safe: the buffer is empty after the first call.
276+
func (t *DBTransaction) FlushEvents(ctx context.Context) {
277+
pending := t.pending
278+
t.pending = nil
279+
for _, e := range pending {
280+
logger.DebugfContext(ctx, "publish new event %v", e)
281+
t.Notifier.Publish(e)
282+
}
249283
}
250284

251-
// Rollback cancels all changes made in the transaction.
285+
// Rollback cancels all changes made in the transaction and discards the events
286+
// recorded for it, so that nothing is published for a transaction that never
287+
// reached the store.
252288
func (t *DBTransaction) Rollback() error {
289+
t.pending = nil
290+
253291
return t.Tx.Rollback()
254292
}
255293

256-
// Commit persists all changes made in the transaction.
257-
func (t *DBTransaction) Commit() error {
258-
return t.Tx.Commit()
294+
// Commit persists all changes made in the transaction and, only if that succeeds,
295+
// publishes the events recorded for it.
296+
func (t *DBTransaction) Commit(ctx context.Context) error {
297+
if err := t.Tx.Commit(); err != nil {
298+
return err
299+
}
300+
t.FlushEvents(ctx)
301+
302+
return nil
259303
}
260304

261305
// SetSpendableFlag updates the spendable status for the given tokens in the database.

token/services/tokens/storage_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ func TestTransaction_Notify(t *testing.T) {
7979
mockTx.GetTokenReturns(&token2.Token{Type: "TOK"}, []string{"alice"}, nil)
8080
err = tx.DeleteTokens(ctx, "me", ids)
8181
require.NoError(t, err)
82+
// the transaction is still open, nothing may be published yet
83+
assert.Equal(t, 0, pub.PublishCallCount())
84+
85+
require.NoError(t, tx.Commit(ctx))
8286
assert.Equal(t, 1, pub.PublishCallCount())
87+
assert.Equal(t, tokens.DeleteToken, pub.PublishArgsForCall(0).Topic())
8388
}
8489

8590
func TestTransaction_AppendToken_Notify(t *testing.T) {
@@ -101,6 +106,146 @@ func TestTransaction_AppendToken_Notify(t *testing.T) {
101106
}
102107
err = tx.AppendToken(ctx, tta)
103108
require.NoError(t, err)
109+
// the transaction is still open, nothing may be published yet
110+
assert.Equal(t, 0, pub.PublishCallCount())
111+
112+
require.NoError(t, tx.Commit(ctx))
113+
require.Equal(t, 1, pub.PublishCallCount())
114+
e := pub.PublishArgsForCall(0)
115+
assert.Equal(t, tokens.AddToken, e.Topic())
116+
assert.Equal(t, tokens.TokenMessage{
117+
TMSID: tmsID,
118+
WalletID: "wallet1",
119+
TokenType: "TOK",
120+
TxID: "tx1",
121+
Index: 0,
122+
}, e.Message())
123+
}
124+
125+
// TestTransaction_AppendToken_NoEventBeforeCommit is the reproduction reported in
126+
// issue #2183: an add-token event must not escape while the transaction that stored
127+
// the token is still open, because the owner of that transaction may still roll it
128+
// back — and a published event cannot be retracted.
129+
func TestTransaction_AppendToken_NoEventBeforeCommit(t *testing.T) {
130+
ctx := context.Background()
131+
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}
132+
mockTx := &mock.FakeTokenStoreTransaction{}
133+
pub := &mock.FakePublisher{}
134+
135+
tx, err := tokens.NewTransaction(pub, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
136+
require.NoError(t, err)
137+
138+
tta := tokens.TokenToAppend{
139+
TxID: "tx1",
140+
Index: 0,
141+
Tok: &token2.Token{Type: "TOK", Owner: []byte("alice"), Quantity: "0x64"},
142+
Precision: 64,
143+
Owners: []string{"wallet1"},
144+
Flags: tokens.Flags{Mine: true},
145+
}
146+
require.NoError(t, tx.AppendToken(ctx, tta))
147+
require.Equal(t, 0, pub.PublishCallCount())
148+
149+
// the owner of the transaction decides to roll back
150+
require.NoError(t, tx.Rollback())
151+
assert.Equal(t, 1, mockTx.RollbackCallCount())
152+
assert.Equal(t, 0, pub.PublishCallCount())
153+
}
154+
155+
// TestTransaction_Commit_PublishesRecordedEventsInOrder checks that every event
156+
// recorded by the transaction is published on commit, once per owner, in the order
157+
// in which it was recorded.
158+
func TestTransaction_Commit_PublishesRecordedEventsInOrder(t *testing.T) {
159+
ctx := context.Background()
160+
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}
161+
mockTx := &mock.FakeTokenStoreTransaction{}
162+
pub := &mock.FakePublisher{}
163+
164+
tx, err := tokens.NewTransaction(pub, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
165+
require.NoError(t, err)
166+
167+
require.NoError(t, tx.AppendToken(ctx, tokens.TokenToAppend{
168+
TxID: "tx1",
169+
Index: 0,
170+
Tok: &token2.Token{Type: "TOK", Owner: []byte("alice"), Quantity: "0x64"},
171+
Precision: 64,
172+
Owners: []string{"wallet1", "wallet2"},
173+
Flags: tokens.Flags{Mine: true},
174+
}))
175+
mockTx.GetTokenReturns(&token2.Token{Type: "TOK"}, []string{"wallet3"}, nil)
176+
require.NoError(t, tx.DeleteTokens(ctx, "me", []*token2.ID{{TxId: "tx0", Index: 3}}))
177+
require.Equal(t, 0, pub.PublishCallCount())
178+
179+
require.NoError(t, tx.Commit(ctx))
180+
require.Equal(t, 3, pub.PublishCallCount())
181+
182+
expected := []tokens.TokenMessage{
183+
{TMSID: tmsID, WalletID: "wallet1", TokenType: "TOK", TxID: "tx1", Index: 0},
184+
{TMSID: tmsID, WalletID: "wallet2", TokenType: "TOK", TxID: "tx1", Index: 0},
185+
{TMSID: tmsID, WalletID: "wallet3", TokenType: "TOK", TxID: "tx0", Index: 3},
186+
}
187+
expectedTopics := []string{tokens.AddToken, tokens.AddToken, tokens.DeleteToken}
188+
for i, msg := range expected {
189+
e := pub.PublishArgsForCall(i)
190+
assert.Equal(t, expectedTopics[i], e.Topic())
191+
assert.Equal(t, msg, e.Message())
192+
}
193+
}
194+
195+
// TestTransaction_Commit_Error_PublishesNothing checks that a failed commit publishes
196+
// nothing: the tokens were not persisted, so no subscriber may learn about them.
197+
func TestTransaction_Commit_Error_PublishesNothing(t *testing.T) {
198+
ctx := context.Background()
199+
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}
200+
mockTx := &mock.FakeTokenStoreTransaction{}
201+
pub := &mock.FakePublisher{}
202+
203+
tx, err := tokens.NewTransaction(pub, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
204+
require.NoError(t, err)
205+
206+
require.NoError(t, tx.AppendToken(ctx, tokens.TokenToAppend{
207+
TxID: "tx1",
208+
Index: 0,
209+
Tok: &token2.Token{Type: "TOK", Owner: []byte("alice"), Quantity: "0x64"},
210+
Precision: 64,
211+
Owners: []string{"wallet1"},
212+
Flags: tokens.Flags{Mine: true},
213+
}))
214+
215+
mockTx.CommitReturns(assert.AnError)
216+
require.ErrorIs(t, tx.Commit(ctx), assert.AnError)
217+
assert.Equal(t, 0, pub.PublishCallCount())
218+
}
219+
220+
// TestTransaction_FlushEvents_Idempotent checks that publishing the recorded events
221+
// twice does not duplicate them. The owner of a continued transaction may hold on to
222+
// the flush returned by AppendValid, so a second call must be harmless.
223+
func TestTransaction_FlushEvents_Idempotent(t *testing.T) {
224+
ctx := context.Background()
225+
tmsID := token.TMSID{Network: "net", Channel: "ch", Namespace: "ns"}
226+
mockTx := &mock.FakeTokenStoreTransaction{}
227+
pub := &mock.FakePublisher{}
228+
229+
tx, err := tokens.NewTransaction(pub, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
230+
require.NoError(t, err)
231+
232+
require.NoError(t, tx.AppendToken(ctx, tokens.TokenToAppend{
233+
TxID: "tx1",
234+
Index: 0,
235+
Tok: &token2.Token{Type: "TOK", Owner: []byte("alice"), Quantity: "0x64"},
236+
Precision: 64,
237+
Owners: []string{"wallet1"},
238+
Flags: tokens.Flags{Mine: true},
239+
}))
240+
241+
tx.FlushEvents(ctx)
242+
require.Equal(t, 1, pub.PublishCallCount())
243+
244+
tx.FlushEvents(ctx)
245+
assert.Equal(t, 1, pub.PublishCallCount())
246+
247+
// committing afterwards must not publish the events again either
248+
require.NoError(t, tx.Commit(ctx))
104249
assert.Equal(t, 1, pub.PublishCallCount())
105250
}
106251

@@ -140,6 +285,10 @@ func TestTransaction_Notify_NoPublisher(t *testing.T) {
140285
tx, err := tokens.NewTransaction(nil, &tokendb.Transaction{TokenStoreTransaction: mockTx}, tmsID)
141286
require.NoError(t, err)
142287
tx.Notify(ctx, tokens.AddToken, tmsID, "wallet1", "TOK", "tx1", 0)
288+
289+
// nothing was recorded, so the commit has nothing to publish either
290+
require.NoError(t, tx.Commit(ctx))
291+
assert.Equal(t, 1, mockTx.CommitCallCount())
143292
}
144293

145294
func TestTransaction_Rollback(t *testing.T) {

0 commit comments

Comments
 (0)