1
0

client_update_fanout_test.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  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) DeleteClient(ctx context.Context, email string) error {
  64. b.wait()
  65. return b.fakeNodeRuntime.DeleteClient(ctx, email)
  66. }
  67. func (b *applyBarrierRuntime) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
  68. b.wait()
  69. return b.fakeNodeRuntime.DeleteUser(ctx, ib, email)
  70. }
  71. // seedClientAcrossNodes creates one client on nodes separate node inbounds and
  72. // returns its record id, with the barrier still disarmed.
  73. func seedClientAcrossNodes(t *testing.T, bar *applyBarrierRuntime, nodes int, basePort int, email, uuid string) int {
  74. t.Helper()
  75. mgr := useTestRuntimeManager(t)
  76. ids := fanoutNodeInbounds(t, mgr, bar, nodes, basePort)
  77. if _, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
  78. Client: model.Client{Email: email, ID: uuid, SubID: "sub-" + email, Enable: true},
  79. InboundIds: ids,
  80. }); err != nil {
  81. t.Fatalf("seed Create across %d node inbounds: %v", nodes, err)
  82. }
  83. return lookupClientRecord(t, email).Id
  84. }
  85. // TestUpdateAcrossNodesPushesConcurrently pins that editing a client attached to
  86. // several node inbounds pushes to them at once. Sequentially the per-node
  87. // round-trips add up, so an edit on a multi-node master cost one RPC per node.
  88. func TestUpdateAcrossNodesPushesConcurrently(t *testing.T) {
  89. setupBulkDB(t)
  90. startSerializedWriter(t)
  91. const nodes = inboundFanoutConcurrency + 1
  92. const uuid = "aaaaaaaa-1111-2222-3333-444444444444"
  93. bar := newApplyBarrier(inboundFanoutConcurrency)
  94. recID := seedClientAcrossNodes(t, bar, nodes, 45101, "upfan@x", uuid)
  95. bar.arm()
  96. if _, err := (&ClientService{}).Update(&InboundService{}, recID, model.Client{
  97. Email: "upfan@x", ID: uuid, SubID: "sub-upfan@x", Enable: true, Comment: "edited",
  98. }, 0); err != nil {
  99. t.Fatalf("Update across %d node inbounds: %v", nodes, err)
  100. }
  101. if got := bar.updateUser.Load(); got != nodes {
  102. t.Fatalf("UpdateUser pushes = %d, want %d", got, nodes)
  103. }
  104. if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
  105. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  106. got, inboundFanoutConcurrency, bar.expired.Load())
  107. }
  108. }
  109. // TestDeleteAcrossNodesPushesConcurrently is the delete-side twin of the update
  110. // test above: removing a client must not cost one node round-trip per node.
  111. func TestDeleteAcrossNodesPushesConcurrently(t *testing.T) {
  112. setupBulkDB(t)
  113. startSerializedWriter(t)
  114. const nodes = inboundFanoutConcurrency + 1
  115. const uuid = "bbbbbbbb-1111-2222-3333-444444444444"
  116. bar := newApplyBarrier(inboundFanoutConcurrency)
  117. recID := seedClientAcrossNodes(t, bar, nodes, 45201, "delfan@x", uuid)
  118. bar.arm()
  119. if _, err := (&ClientService{}).Delete(&InboundService{}, recID, false); err != nil {
  120. t.Fatalf("Delete across %d node inbounds: %v", nodes, err)
  121. }
  122. if got := bar.deleteClient.Load(); got != nodes {
  123. t.Fatalf("DeleteClient pushes = %d, want %d", got, nodes)
  124. }
  125. if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
  126. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  127. got, inboundFanoutConcurrency, bar.expired.Load())
  128. }
  129. }
  130. // TestDetachAcrossNodesPushesConcurrently covers the third sequential loop: a
  131. // bulk detach walks the same per-inbound node push as update and delete.
  132. func TestDetachAcrossNodesPushesConcurrently(t *testing.T) {
  133. setupBulkDB(t)
  134. startSerializedWriter(t)
  135. const nodes = inboundFanoutConcurrency + 1
  136. const uuid = "cccccccc-1111-2222-3333-444444444444"
  137. bar := newApplyBarrier(inboundFanoutConcurrency)
  138. recID := seedClientAcrossNodes(t, bar, nodes, 45301, "detfan@x", uuid)
  139. ids, err := (&ClientService{}).GetInboundIdsForRecord(recID)
  140. if err != nil {
  141. t.Fatalf("GetInboundIdsForRecord: %v", err)
  142. }
  143. bar.arm()
  144. if _, err := (&ClientService{}).Detach(&InboundService{}, recID, ids); err != nil {
  145. t.Fatalf("Detach across %d node inbounds: %v", nodes, err)
  146. }
  147. if got := bar.deleteUser.Load(); got != nodes {
  148. t.Fatalf("DeleteUser pushes = %d, want %d", got, nodes)
  149. }
  150. if got := bar.maxPar.Load(); got != inboundFanoutConcurrency {
  151. t.Fatalf("peak node pushes in flight = %d, want overlap at the %d cap (barrier timed out: %v)",
  152. got, inboundFanoutConcurrency, bar.expired.Load())
  153. }
  154. }