client_update_fanout_test.go 5.6 KB

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