client_update_fanout_test.go 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. package service
  2. import (
  3. "context"
  4. "sync/atomic"
  5. "testing"
  6. "time"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. )
  9. // applyBarrierRuntime holds every armed node push until fanout of them are
  10. // inside it at once; a sequential caller only ever reaches one and times out.
  11. // It stays pass-through until arm() so a test can seed its clients first.
  12. type applyBarrierRuntime struct {
  13. fakeNodeRuntime
  14. fanout int32
  15. armed atomic.Bool
  16. inFlight atomic.Int32
  17. maxPar atomic.Int32
  18. release chan struct{}
  19. freed atomic.Bool
  20. expired atomic.Bool
  21. }
  22. func newApplyBarrier(fanout int32) *applyBarrierRuntime {
  23. return &applyBarrierRuntime{fanout: fanout, release: make(chan struct{})}
  24. }
  25. func (b *applyBarrierRuntime) arm() { b.armed.Store(true) }
  26. func (b *applyBarrierRuntime) free() {
  27. if b.freed.CompareAndSwap(false, true) {
  28. close(b.release)
  29. }
  30. }
  31. func (b *applyBarrierRuntime) wait() {
  32. if !b.armed.Load() {
  33. return
  34. }
  35. n := b.inFlight.Add(1)
  36. for {
  37. peak := b.maxPar.Load()
  38. if n <= peak || b.maxPar.CompareAndSwap(peak, n) {
  39. break
  40. }
  41. }
  42. if n == b.fanout {
  43. b.free()
  44. }
  45. select {
  46. case <-b.release:
  47. case <-time.After(5 * time.Second):
  48. // Release everyone on the first timeout so a sequential regression
  49. // fails once instead of stalling for fanout x the wait.
  50. b.expired.Store(true)
  51. b.free()
  52. }
  53. b.inFlight.Add(-1)
  54. }
  55. func (b *applyBarrierRuntime) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, c model.Client) error {
  56. b.wait()
  57. return b.fakeNodeRuntime.UpdateUser(ctx, ib, oldEmail, c)
  58. }
  59. func (b *applyBarrierRuntime) AddClient(ctx context.Context, ib *model.Inbound, c model.Client) error {
  60. b.wait()
  61. return b.fakeNodeRuntime.AddClient(ctx, ib, c)
  62. }
  63. func (b *applyBarrierRuntime) ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error {
  64. b.wait()
  65. return b.fakeNodeRuntime.ResetClientTraffic(ctx, ib, email)
  66. }
  67. func (b *applyBarrierRuntime) DeleteClient(ctx context.Context, email string) error {
  68. b.wait()
  69. return b.fakeNodeRuntime.DeleteClient(ctx, email)
  70. }
  71. func (b *applyBarrierRuntime) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
  72. b.wait()
  73. return b.fakeNodeRuntime.DeleteUser(ctx, ib, email)
  74. }
  75. // seedClientAcrossNodes creates one client on nodes separate node inbounds and
  76. // returns its record id, with the barrier still disarmed.
  77. func seedClientAcrossNodes(t *testing.T, bar *applyBarrierRuntime, nodes int, basePort int, email, uuid string) int {
  78. t.Helper()
  79. mgr := useTestRuntimeManager(t)
  80. ids := fanoutNodeInbounds(t, mgr, bar, nodes, basePort)
  81. if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
  82. Client: model.Client{Email: email, ID: uuid, SubID: "sub-" + email, Enable: true},
  83. InboundIds: ids,
  84. }); err != nil {
  85. t.Fatalf("seed Create across %d node inbounds: %v", nodes, err)
  86. }
  87. return lookupClientRecord(t, email).Id
  88. }
  89. // TestUpdateAcrossNodesPushesConcurrently pins that editing a client attached to
  90. // several node inbounds pushes to them at once. Sequentially the per-node
  91. // round-trips add up, so an edit on a multi-node master cost one RPC per node.
  92. func TestUpdateAcrossNodesPushesConcurrently(t *testing.T) {
  93. setupBulkDB(t)
  94. startSerializedWriter(t)
  95. const nodes = inboundFanoutConcurrency + 1
  96. const uuid = "aaaaaaaa-1111-2222-3333-444444444444"
  97. bar := newApplyBarrier(inboundFanoutConcurrency)
  98. recID := seedClientAcrossNodes(t, bar, nodes, 45101, "upfan@x", uuid)
  99. bar.arm()
  100. if _, err := (&ClientService{}).Update(&InboundService{}, recID, model.Client{
  101. Email: "upfan@x", ID: uuid, SubID: "sub-upfan@x", Enable: true, Comment: "edited",
  102. }, 0); err != nil {
  103. t.Fatalf("Update across %d node inbounds: %v", nodes, err)
  104. }
  105. if got := bar.updateUser.Load(); got != nodes {
  106. t.Fatalf("UpdateUser pushes = %d, want %d", got, nodes)
  107. }
  108. if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
  109. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  110. got, inboundFanoutConcurrency, bar.expired.Load())
  111. }
  112. }
  113. // TestDeleteAcrossNodesPushesConcurrently is the delete-side twin of the update
  114. // test above: removing a client must not cost one node round-trip per node.
  115. func TestDeleteAcrossNodesPushesConcurrently(t *testing.T) {
  116. setupBulkDB(t)
  117. startSerializedWriter(t)
  118. const nodes = inboundFanoutConcurrency + 1
  119. const uuid = "bbbbbbbb-1111-2222-3333-444444444444"
  120. bar := newApplyBarrier(inboundFanoutConcurrency)
  121. recID := seedClientAcrossNodes(t, bar, nodes, 45201, "delfan@x", uuid)
  122. bar.arm()
  123. if _, err := (&ClientService{}).Delete(&InboundService{}, recID, false); err != nil {
  124. t.Fatalf("Delete across %d node inbounds: %v", nodes, err)
  125. }
  126. if got := bar.deleteClient.Load(); got != nodes {
  127. t.Fatalf("DeleteClient pushes = %d, want %d", got, nodes)
  128. }
  129. if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
  130. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  131. got, inboundFanoutConcurrency, bar.expired.Load())
  132. }
  133. }
  134. // TestDetachAcrossNodesPushesConcurrently covers the third sequential loop: a
  135. // bulk detach walks the same per-inbound node push as update and delete.
  136. func TestDetachAcrossNodesPushesConcurrently(t *testing.T) {
  137. setupBulkDB(t)
  138. startSerializedWriter(t)
  139. const nodes = inboundFanoutConcurrency + 1
  140. const uuid = "cccccccc-1111-2222-3333-444444444444"
  141. bar := newApplyBarrier(inboundFanoutConcurrency)
  142. recID := seedClientAcrossNodes(t, bar, nodes, 45301, "detfan@x", uuid)
  143. ids, err := (&ClientService{}).GetInboundIdsForRecord(recID)
  144. if err != nil {
  145. t.Fatalf("GetInboundIdsForRecord: %v", err)
  146. }
  147. bar.arm()
  148. if _, err := (&ClientService{}).Detach(&InboundService{}, recID, ids); err != nil {
  149. t.Fatalf("Detach across %d node inbounds: %v", nodes, err)
  150. }
  151. if got := bar.deleteUser.Load(); got != nodes {
  152. t.Fatalf("DeleteUser pushes = %d, want %d", got, nodes)
  153. }
  154. if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
  155. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  156. got, inboundFanoutConcurrency, bar.expired.Load())
  157. }
  158. }