perip.go 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. // Package concurrency is the in-memory per-IP concurrency cap
  2. // for ingestd (SPEC §22 layer 2). It is shared by:
  3. //
  4. // - cmd/ingestd's HTTP server (via the httpserver.ConnState
  5. // callback that the http handler installs)
  6. // - cmd/ingestd's WebSocket ingest path (incremented on upgrade,
  7. // decremented on close)
  8. //
  9. // Why in-memory and not Redis: this is the DoS gate. A Redis
  10. // round-trip on every connection would self-DoS. The cap is
  11. // approximate (a few extra conns during a multi-replica failover
  12. // is fine). A janitor goroutine prunes idle entries every minute
  13. // so the map doesn't grow unbounded.
  14. package concurrency
  15. import (
  16. "sync"
  17. "sync/atomic"
  18. "time"
  19. )
  20. // PerIP is a per-source-IP concurrency cap. Each unique IP gets
  21. // an atomic counter; Acquire returns false if the cap is hit.
  22. // The cap is a hard cap; one process's limit is independent of
  23. // other ingestd replicas (which is what we want — each replica
  24. // guards its own TCP listener).
  25. type PerIP struct {
  26. cap int64
  27. mu sync.Mutex
  28. conns map[string]*atomic.Int64
  29. idle map[string]time.Time // last released-at
  30. stop chan struct{}
  31. }
  32. // NewPerIP creates a PerIP with the given cap. The cap is the
  33. // maximum concurrent connections per IP. A non-positive cap
  34. // disables the gate (Acquire always returns true).
  35. //
  36. // The janitor runs every minute and prunes IPs whose counter is
  37. // zero and whose last-release is older than 5 min. This bounds
  38. // the map size to "IPs that hit ingestd in the last 5 min",
  39. // which is small.
  40. func NewPerIP(cap int) *PerIP {
  41. p := &PerIP{
  42. cap: int64(cap),
  43. conns: make(map[string]*atomic.Int64),
  44. idle: make(map[string]time.Time),
  45. stop: make(chan struct{}),
  46. }
  47. go p.janitor()
  48. return p
  49. }
  50. // Close stops the janitor. Safe to call multiple times.
  51. func (p *PerIP) Close() {
  52. select {
  53. case <-p.stop:
  54. default:
  55. close(p.stop)
  56. }
  57. }
  58. // Acquire tries to increment the counter for ip. Returns true on
  59. // success, false if the cap is hit. Always increments on success;
  60. // the caller MUST call Release exactly once per successful
  61. // Acquire when the connection closes.
  62. func (p *PerIP) Acquire(ip string) bool {
  63. if p.cap <= 0 {
  64. return true // gate disabled
  65. }
  66. c := p.counter(ip)
  67. // Atomic add; if the result exceeds the cap, decrement back
  68. // and reject. This is the standard compare-and-swap pattern
  69. // for admission control.
  70. n := c.Add(1)
  71. if n > p.cap {
  72. c.Add(-1)
  73. return false
  74. }
  75. p.touchActive(ip)
  76. return true
  77. }
  78. // Release decrements the counter for ip. Safe to call without a
  79. // matching Acquire (the counter clamps at 0; we use Add(-1) but
  80. // the case where the result goes negative is treated as a no-op
  81. // because the caller is misbehaving — log via Warn, not panic).
  82. func (p *PerIP) Release(ip string) {
  83. if p.cap <= 0 {
  84. return
  85. }
  86. p.mu.Lock()
  87. c, ok := p.conns[ip]
  88. p.mu.Unlock()
  89. if !ok {
  90. return
  91. }
  92. n := c.Add(-1)
  93. if n < 0 {
  94. c.Add(1) // clamp
  95. return
  96. }
  97. p.touchIdle(ip)
  98. }
  99. // InUse returns the current count for ip (0 if not tracked).
  100. // Useful for tests and the operator's /v1/debug/perip endpoint.
  101. func (p *PerIP) InUse(ip string) int64 {
  102. p.mu.Lock()
  103. c, ok := p.conns[ip]
  104. p.mu.Unlock()
  105. if !ok {
  106. return 0
  107. }
  108. v := c.Load()
  109. if v < 0 {
  110. return 0
  111. }
  112. return v
  113. }
  114. func (p *PerIP) counter(ip string) *atomic.Int64 {
  115. p.mu.Lock()
  116. defer p.mu.Unlock()
  117. c, ok := p.conns[ip]
  118. if !ok {
  119. c = &atomic.Int64{}
  120. p.conns[ip] = c
  121. }
  122. return c
  123. }
  124. func (p *PerIP) touchActive(ip string) {
  125. p.mu.Lock()
  126. defer p.mu.Unlock()
  127. delete(p.idle, ip)
  128. }
  129. func (p *PerIP) touchIdle(ip string) {
  130. p.mu.Lock()
  131. defer p.mu.Unlock()
  132. p.idle[ip] = time.Now()
  133. }
  134. // janitor prunes idle entries whose counter is 0. The 5-min
  135. // window is wide enough that a reconnecting source doesn't lose
  136. // its slot but narrow enough that the map doesn't grow
  137. // unbounded over weeks of traffic.
  138. func (p *PerIP) janitor() {
  139. t := time.NewTicker(time.Minute)
  140. defer t.Stop()
  141. for {
  142. select {
  143. case <-p.stop:
  144. return
  145. case now := <-t.C:
  146. p.mu.Lock()
  147. for ip, last := range p.idle {
  148. if now.Sub(last) > 5*time.Minute {
  149. c := p.conns[ip]
  150. if c != nil && c.Load() == 0 {
  151. delete(p.conns, ip)
  152. delete(p.idle, ip)
  153. }
  154. }
  155. }
  156. p.mu.Unlock()
  157. }
  158. }
  159. }