Skip to content

Commit 1fb22a3

Browse files
authored
Initial worker to retry triggering the Swap RPC on client (#122)
1 parent 8f01d17 commit 1fb22a3

File tree

13 files changed

+476
-22
lines changed

13 files changed

+476
-22
lines changed

pkg/code/async/account/metrics.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import (
88
)
99

1010
const (
11-
giftCardWorkerEventName = "GiftCardWorkerPollingCheck"
11+
giftCardWorkerEventName = "GiftCardWorkerPollingCheck"
12+
swapRetryWorkerEventName = "SwapRetryWorkerPollingCheck"
1213
)
1314

1415
func (p *service) metricsGaugeWorker(ctx context.Context) error {
@@ -30,11 +31,16 @@ func (p *service) metricsGaugeWorker(ctx context.Context) error {
3031

3132
func (p *service) recordBackupQueueStatusPollingEvent(ctx context.Context) {
3233
count, err := p.data.GetAccountInfoCountRequiringAutoReturnCheck(ctx)
33-
if err != nil {
34-
return
34+
if err == nil {
35+
metrics.RecordEvent(ctx, giftCardWorkerEventName, map[string]interface{}{
36+
"queue_size": count,
37+
})
3538
}
3639

37-
metrics.RecordEvent(ctx, giftCardWorkerEventName, map[string]interface{}{
38-
"queue_size": count,
39-
})
40+
count, err = p.data.GetAccountInfoCountRequiringSwapRetry(ctx)
41+
if err == nil {
42+
metrics.RecordEvent(ctx, swapRetryWorkerEventName, map[string]interface{}{
43+
"queue_size": count,
44+
})
45+
}
4046
}

pkg/code/async/account/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ func (p *service) Start(ctx context.Context, interval time.Duration) error {
3535
}
3636
}()
3737

38+
go func() {
39+
err := p.swapRetryWorker(ctx, interval)
40+
if err != nil && err != context.Canceled {
41+
p.log.WithError(err).Warn("swap retry processing loop terminated unexpectedly")
42+
}
43+
}()
44+
3845
go func() {
3946
err := p.metricsGaugeWorker(ctx)
4047
if err != nil && err != context.Canceled {

pkg/code/async/account/swap.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package async_account
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"time"
8+
9+
"github.com/newrelic/go-agent/v3/newrelic"
10+
"github.com/sirupsen/logrus"
11+
12+
commonpb "github.com/code-payments/code-protobuf-api/generated/go/common/v1"
13+
14+
"github.com/code-payments/code-server/pkg/code/balance"
15+
"github.com/code-payments/code-server/pkg/code/common"
16+
code_data "github.com/code-payments/code-server/pkg/code/data"
17+
"github.com/code-payments/code-server/pkg/code/data/account"
18+
"github.com/code-payments/code-server/pkg/code/push"
19+
"github.com/code-payments/code-server/pkg/metrics"
20+
"github.com/code-payments/code-server/pkg/retry"
21+
"github.com/code-payments/code-server/pkg/usdc"
22+
)
23+
24+
const (
25+
swapPushRetryThreshold = 5 * time.Minute
26+
minUsdcSwapBalance = usdc.QuarksPerUsdc / 100 // $0.01 USD
27+
)
28+
29+
func (p *service) swapRetryWorker(serviceCtx context.Context, interval time.Duration) error {
30+
delay := interval
31+
32+
err := retry.Loop(
33+
func() (err error) {
34+
time.Sleep(delay)
35+
36+
nr := serviceCtx.Value(metrics.NewRelicContextKey).(*newrelic.Application)
37+
m := nr.StartTransaction("async__account_service__handle_swap_retry")
38+
defer m.End()
39+
tracedCtx := newrelic.NewContext(serviceCtx, m)
40+
41+
records, err := p.data.GetPrioritizedAccountInfosRequiringSwapRetry(tracedCtx, swapPushRetryThreshold, 100)
42+
if err == account.ErrAccountInfoNotFound {
43+
return nil
44+
} else if err != nil {
45+
m.NoticeError(err)
46+
return err
47+
}
48+
49+
var wg sync.WaitGroup
50+
for _, record := range records {
51+
wg.Add(1)
52+
53+
go func(record *account.Record) {
54+
defer wg.Done()
55+
56+
err := p.maybeTriggerAnotherSwap(tracedCtx, record)
57+
if err != nil {
58+
m.NoticeError(err)
59+
}
60+
}(record)
61+
}
62+
wg.Wait()
63+
64+
return nil
65+
},
66+
retry.NonRetriableErrors(context.Canceled),
67+
)
68+
69+
return err
70+
}
71+
72+
// todo: Handle the case where there are no push tokens
73+
func (p *service) maybeTriggerAnotherSwap(ctx context.Context, accountInfoRecord *account.Record) error {
74+
log := p.log.WithFields(logrus.Fields{
75+
"method": "maybeTriggerAnotherSwap",
76+
"owner_account": accountInfoRecord.OwnerAccount,
77+
"token_account": accountInfoRecord.TokenAccount,
78+
})
79+
80+
if accountInfoRecord.AccountType != commonpb.AccountType_SWAP {
81+
log.Trace("skipping account that isn't a swap account")
82+
return errors.New("expected a swap account")
83+
}
84+
85+
ownerAccount, err := common.NewAccountFromPublicKeyString(accountInfoRecord.OwnerAccount)
86+
if err != nil {
87+
log.WithError(err).Warn("invalid owner account")
88+
return err
89+
}
90+
91+
tokenAccount, err := common.NewAccountFromPublicKeyString(accountInfoRecord.TokenAccount)
92+
if err != nil {
93+
log.WithError(err).Warn("invalid token account")
94+
return err
95+
}
96+
97+
balance, _, err := balance.CalculateFromBlockchain(ctx, p.data, tokenAccount)
98+
if err != nil {
99+
log.WithError(err).Warn("failure getting token account balance")
100+
return err
101+
}
102+
103+
// Mark the swap as successful if the account is left with only dust
104+
if balance < minUsdcSwapBalance {
105+
err = markSwapRetrySuccessful(ctx, p.data, accountInfoRecord)
106+
if err != nil {
107+
log.WithError(err).Warn("failure updating account info record")
108+
}
109+
return err
110+
}
111+
112+
err = push.SendTriggerSwapRpcPushNotification(
113+
ctx,
114+
p.data,
115+
p.pusher,
116+
ownerAccount,
117+
)
118+
if err != nil {
119+
log.WithError(err).Warn("failure sending push notification")
120+
}
121+
122+
err = markSwapRetriedNow(ctx, p.data, accountInfoRecord)
123+
if err != nil {
124+
log.WithError(err).Warn("failure updating account info record")
125+
}
126+
return err
127+
}
128+
129+
func markSwapRetrySuccessful(ctx context.Context, data code_data.Provider, record *account.Record) error {
130+
if !record.RequiresSwapRetry {
131+
return nil
132+
}
133+
134+
record.RequiresSwapRetry = false
135+
return data.UpdateAccountInfo(ctx, record)
136+
}
137+
138+
func markSwapRetriedNow(ctx context.Context, data code_data.Provider, record *account.Record) error {
139+
if !record.RequiresSwapRetry {
140+
return nil
141+
}
142+
143+
record.LastSwapRetryAt = time.Now()
144+
return data.UpdateAccountInfo(ctx, record)
145+
}

pkg/code/async/account/swap_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package async_account
2+
3+
// todo: add tests

pkg/code/async/geyser/external_deposit.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
chat_util "github.com/code-payments/code-server/pkg/code/chat"
1919
"github.com/code-payments/code-server/pkg/code/common"
2020
code_data "github.com/code-payments/code-server/pkg/code/data"
21+
"github.com/code-payments/code-server/pkg/code/data/account"
2122
"github.com/code-payments/code-server/pkg/code/data/balance"
2223
"github.com/code-payments/code-server/pkg/code/data/chat"
2324
"github.com/code-payments/code-server/pkg/code/data/deposit"
@@ -343,14 +344,18 @@ func processPotentialExternalDeposit(ctx context.Context, conf *conf, data code_
343344
return errors.Wrap(err, "invalid owner account")
344345
}
345346

346-
err = push.SendTriggerSwapRpcPushNotification(
347+
// Best-effort attempt to get the client to trigger a Swap RPC call now
348+
go push.SendTriggerSwapRpcPushNotification(
347349
ctx,
348350
data,
349351
pusher,
350352
owner,
351353
)
354+
355+
// Have the account pulled by the swap retry worker
356+
err = markRequiringSwapRetries(ctx, data, accountInfoRecord)
352357
if err != nil {
353-
return errors.Wrap(err, "error sending push to trigger swap rpc on client")
358+
return err
354359
}
355360

356361
syncedDepositCache.Insert(cacheKey, true, 1)
@@ -383,6 +388,20 @@ func markDepositsAsSynced(ctx context.Context, data code_data.Provider, vault *c
383388
return nil
384389
}
385390

391+
func markRequiringSwapRetries(ctx context.Context, data code_data.Provider, accountInfoRecord *account.Record) error {
392+
if accountInfoRecord.RequiresSwapRetry {
393+
return nil
394+
}
395+
396+
accountInfoRecord.RequiresSwapRetry = true
397+
accountInfoRecord.LastSwapRetryAt = time.Now()
398+
err := data.UpdateAccountInfo(ctx, accountInfoRecord)
399+
if err != nil {
400+
return errors.Wrap(err, "error updating swap account info")
401+
}
402+
return nil
403+
}
404+
386405
// todo: can be promoted more broadly
387406
func getDeltaQuarksFromTokenBalances(tokenAccount *common.Account, tokenBalances *solana.TransactionTokenBalances) (int64, error) {
388407
var preQuarkBalance, postQuarkBalance int64

pkg/code/data/account/acccount_info.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ type Record struct {
4343

4444
RequiresAutoReturnCheck bool
4545

46+
RequiresSwapRetry bool
47+
LastSwapRetryAt time.Time
48+
4649
CreatedAt time.Time
4750
}
4851

@@ -74,6 +77,8 @@ func (r *Record) Clone() Record {
7477
RequiresDepositSync: r.RequiresDepositSync,
7578
DepositsLastSyncedAt: r.DepositsLastSyncedAt,
7679
RequiresAutoReturnCheck: r.RequiresAutoReturnCheck,
80+
RequiresSwapRetry: r.RequiresSwapRetry,
81+
LastSwapRetryAt: r.LastSwapRetryAt,
7782
CreatedAt: r.CreatedAt,
7883
}
7984
}
@@ -90,6 +95,8 @@ func (r *Record) CopyTo(dst *Record) {
9095
dst.RequiresDepositSync = r.RequiresDepositSync
9196
dst.DepositsLastSyncedAt = r.DepositsLastSyncedAt
9297
dst.RequiresAutoReturnCheck = r.RequiresAutoReturnCheck
98+
dst.RequiresSwapRetry = r.RequiresSwapRetry
99+
dst.LastSwapRetryAt = r.LastSwapRetryAt
93100
dst.CreatedAt = r.CreatedAt
94101
}
95102

@@ -185,6 +192,10 @@ func (r *Record) Validate() error {
185192
return errors.New("only remote send gift cards can have auto-return checks")
186193
}
187194

195+
if r.RequiresSwapRetry && r.AccountType != commonpb.AccountType_SWAP {
196+
return errors.New("only swap accounts can require swap retries")
197+
}
198+
188199
if r.RelationshipTo != nil && r.AccountType != commonpb.AccountType_RELATIONSHIP {
189200
return errors.New("only relationship accounts can have a relationship metadata")
190201
}

pkg/code/data/account/memory/store.go

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ func (a ByCreatedAt) Less(i, j int) bool {
5151
return a[i].CreatedAt.Unix() < a[j].CreatedAt.Unix()
5252
}
5353

54+
type ByLastSwapRetryAt []*account.Record
55+
56+
func (a ByLastSwapRetryAt) Len() int { return len(a) }
57+
func (a ByLastSwapRetryAt) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
58+
func (a ByLastSwapRetryAt) Less(i, j int) bool {
59+
return a[i].LastSwapRetryAt.Unix() < a[j].LastSwapRetryAt.Unix()
60+
}
61+
5462
func New() account.Store {
5563
return &store{
5664
records: make([]*account.Record, 0),
@@ -131,6 +139,16 @@ func (s *store) findByRequiringAutoReturnCheck(want bool) []*account.Record {
131139
return res
132140
}
133141

142+
func (s *store) findByRequiringSwapRetry(want bool) []*account.Record {
143+
var res []*account.Record
144+
for _, item := range s.records {
145+
if item.RequiresSwapRetry == want {
146+
res = append(res, item)
147+
}
148+
}
149+
return res
150+
}
151+
134152
func (s *store) filterByType(items []*account.Record, accountType commonpb.AccountType) []*account.Record {
135153
var res []*account.Record
136154
for _, item := range items {
@@ -220,6 +238,9 @@ func (s *store) Update(_ context.Context, data *account.Record) error {
220238

221239
item.RequiresAutoReturnCheck = data.RequiresAutoReturnCheck
222240

241+
item.RequiresSwapRetry = data.RequiresSwapRetry
242+
item.LastSwapRetryAt = data.LastSwapRetryAt
243+
223244
item.CopyTo(data)
224245

225246
return nil
@@ -353,7 +374,7 @@ func (s *store) GetPrioritizedRequiringDepositSync(_ context.Context, limit uint
353374
if len(res) > int(limit) {
354375
return res[:limit], nil
355376
}
356-
return res, nil
377+
return cloneRecords(res), nil
357378
}
358379

359380
// CountRequiringDepositSync implements account.Store.CountRequiringDepositSync
@@ -392,7 +413,7 @@ func (s *store) GetPrioritizedRequiringAutoReturnCheck(ctx context.Context, minA
392413
if len(res) > int(limit) {
393414
return res[:limit], nil
394415
}
395-
return res, nil
416+
return cloneRecords(res), nil
396417
}
397418

398419
// CountRequiringAutoReturnCheck implements account.Store.CountRequiringAutoReturnCheck
@@ -404,6 +425,45 @@ func (s *store) CountRequiringAutoReturnCheck(ctx context.Context) (uint64, erro
404425
return uint64(len(items)), nil
405426
}
406427

428+
// GetPrioritizedRequiringSwapRetry implements account.Store.GetPrioritizedRequiringSwapRetry
429+
func (s *store) GetPrioritizedRequiringSwapRetry(ctx context.Context, minAge time.Duration, limit uint64) ([]*account.Record, error) {
430+
s.mu.Lock()
431+
defer s.mu.Unlock()
432+
433+
items := s.findByRequiringSwapRetry(true)
434+
435+
var res []*account.Record
436+
for _, item := range items {
437+
if time.Since(item.LastSwapRetryAt) <= minAge {
438+
continue
439+
}
440+
441+
cloned := item.Clone()
442+
res = append(res, &cloned)
443+
}
444+
445+
if len(res) == 0 {
446+
return nil, account.ErrAccountInfoNotFound
447+
}
448+
449+
sorted := ByLastSwapRetryAt(res)
450+
sort.Sort(sorted)
451+
452+
if len(res) > int(limit) {
453+
return res[:limit], nil
454+
}
455+
return cloneRecords(res), nil
456+
}
457+
458+
// CountRequiringSwapRetry implements account.Store.CountRequiringSwapRetry
459+
func (s *store) CountRequiringSwapRetry(ctx context.Context) (uint64, error) {
460+
s.mu.Lock()
461+
defer s.mu.Unlock()
462+
463+
items := s.findByRequiringSwapRetry(true)
464+
return uint64(len(items)), nil
465+
}
466+
407467
func cloneRecords(items []*account.Record) []*account.Record {
408468
res := make([]*account.Record, len(items))
409469

0 commit comments

Comments
 (0)