diff --git a/api/types/event_webhook.go b/api/types/event_webhook.go index 21d70a54c..6ea0d991b 100644 --- a/api/types/event_webhook.go +++ b/api/types/event_webhook.go @@ -16,6 +16,15 @@ package types +import ( + "encoding/json" + "fmt" + "time" +) + +// DateFormat defines the standard format used for timestamps. +const DateFormat = "2006-01-02T15:04:05.000Z" + // EventWebhookType represents event webhook type type EventWebhookType string @@ -29,14 +38,63 @@ func IsValidEventType(eventType string) bool { return eventType == string(DocRootChanged) } -// EventWebhookAttribute represents the attribute of the webhook. +// EventWebhookAttribute represents metadata associated with a webhook event. type EventWebhookAttribute struct { Key string `json:"key"` IssuedAt string `json:"issuedAt"` } -// EventWebhookRequest represents the request of the webhook. +// EventWebhookRequest represents a webhook event request payload. type EventWebhookRequest struct { Type EventWebhookType `json:"type"` Attributes EventWebhookAttribute `json:"attributes"` } + +// NewRequestBody builds the JSON request body for a webhook event. +func NewRequestBody(docKey string, event EventWebhookType) ([]byte, error) { + req := EventWebhookRequest{ + Type: event, + Attributes: EventWebhookAttribute{ + Key: docKey, + IssuedAt: time.Now().UTC().Format(DateFormat), + }, + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal event webhook request: %w", err) + } + return body, nil +} + +// EventWebhookInfo holds the webhook EventRefKey and its associated Attribute. +type EventWebhookInfo struct { + EventRefKey EventRefKey + Attribute WebhookAttribute +} + +// NewEventWebhookInfo initializes an EventWebhookInfo with the given parameters. +func NewEventWebhookInfo( + docRefKey DocRefKey, + event EventWebhookType, + signingKey, url, docKey string, +) EventWebhookInfo { + return EventWebhookInfo{ + EventRefKey: EventRefKey{ + DocRefKey: docRefKey, + EventWebhookType: event, + }, + Attribute: WebhookAttribute{ + SigningKey: signingKey, + URL: url, + DocKey: docKey, + }, + } +} + +// WebhookAttribute defines attributes necessary for webhook handling. +type WebhookAttribute struct { + SigningKey string + URL string + DocKey string +} diff --git a/api/types/resource_ref_key.go b/api/types/resource_ref_key.go index d295d829a..0cafbee2c 100644 --- a/api/types/resource_ref_key.go +++ b/api/types/resource_ref_key.go @@ -52,3 +52,14 @@ type SnapshotRefKey struct { func (r SnapshotRefKey) String() string { return fmt.Sprintf("Snapshot (%s.%s.%d)", r.ProjectID, r.DocID, r.ServerSeq) } + +// EventRefKey represents an identifier used to reference an event. +type EventRefKey struct { + DocRefKey + EventWebhookType +} + +// String returns the string representation of the given EventRefKey. +func (r EventRefKey) String() string { + return fmt.Sprintf("DocEvent (%s.%s.%s)", r.ProjectID, r.DocID, r.EventWebhookType) +} diff --git a/pkg/limit/bucket.go b/pkg/limit/bucket.go new file mode 100644 index 000000000..52e113771 --- /dev/null +++ b/pkg/limit/bucket.go @@ -0,0 +1,28 @@ +package limit + +import "time" + +// Bucket represents a single-token bucket that refills every specified time window. +type Bucket struct { + window time.Duration // The interval at which the bucket refills. + last time.Time // The last time a token was granted. +} + +// NewBucket creates a new Bucket with the given initial time and refill window. +func NewBucket(now time.Time, window time.Duration) Bucket { + return Bucket{ + window: window, + last: now, + } +} + +// Allow checks if a token can be granted at the given time. +// It returns true if the time has advanced past the refill window, otherwise false. +func (b *Bucket) Allow(now time.Time) bool { + if now.Before(b.last.Add(b.window)) { + return false + } + + b.last = now + return true +} diff --git a/pkg/limit/limiter.go b/pkg/limit/limiter.go new file mode 100644 index 000000000..999a8d746 --- /dev/null +++ b/pkg/limit/limiter.go @@ -0,0 +1,181 @@ +/* + * Copyright 2025 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package limit provides rate-limiting functionality with debouncing support. +package limit + +import ( + "container/list" + "sync" + "time" +) + +// Limiter provides rate limiting functionality with a debouncing callback. +// It maintains a single token bucket. +type Limiter[K comparable] struct { + mu sync.Mutex + wg sync.WaitGroup + closing chan struct{} + + expireInterval time.Duration + throttleWindow time.Duration + debouncingTime time.Duration + expireBatchSize int + + // evictionList holds the limiter entries in order of recency. + evictionList *list.List + // entries maps keys to their corresponding list element for quick lookup. + entries map[K]*list.Element +} + +// NewLimiter creates and returns a new Limiter instance. +// Parameters: +// +// expireInterval: How often to check for expired entries. +// throttleWindow: The time window for rate limiting. +// debouncingTime: The time-to-live for each rate bucket entry. +func NewLimiter[K comparable](expireNum int, expire, throttle, debouncing time.Duration) *Limiter[K] { + lim := &Limiter[K]{ + closing: make(chan struct{}), + expireInterval: expire, + throttleWindow: throttle, + debouncingTime: debouncing, + expireBatchSize: expireNum, + evictionList: list.New(), + entries: make(map[K]*list.Element), + } + + // Start the background expiration process. + lim.wg.Add(1) + go lim.expirationLoop() + return lim +} + +// limiterEntry represents an entry in the Limiter for a specific key. +type limiterEntry[K comparable] struct { + key K + bucket Bucket + expireTime time.Time + debouncingCallback func() +} + +// Allow checks if an event is allowed for the given key based on the rate bucket. +// If allowed, it clears any pending debouncing callback; otherwise, it stores the provided callback. +// It returns true if the event is allowed immediately. +func (l *Limiter[K]) Allow(key K, callback func()) bool { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + if elem, exists := l.entries[key]; exists { + entry := elem.Value.(*limiterEntry[K]) + allowed := entry.bucket.Allow(now) + if allowed { + entry.debouncingCallback = nil + } else { + entry.debouncingCallback = callback + } + // Update recency and extend TTL. + l.evictionList.MoveToFront(elem) + entry.expireTime = now.Add(l.throttleWindow + l.debouncingTime) + return allowed + } + + // Create a new rate bucket for a new key. + bucket := NewBucket(now, l.throttleWindow) + entry := &limiterEntry[K]{ + key: key, + bucket: bucket, + expireTime: now.Add(l.throttleWindow + l.debouncingTime), + } + elem := l.evictionList.PushFront(entry) + l.entries[key] = elem + return true +} + +// expirationLoop runs in a separate goroutine to periodically remove expired entries. +func (l *Limiter[K]) expirationLoop() { + ticker := time.NewTicker(l.expireInterval) + defer func() { + ticker.Stop() + l.wg.Done() + }() + + for { + select { + case <-ticker.C: + expiredEntries := l.collectEntries(true) + l.runDebounce(expiredEntries) + case <-l.closing: + return + } + } +} + +// collectEntries gathers expired entries and removes them from the limiter. +func (l *Limiter[K]) collectEntries(onlyExpired bool) []*limiterEntry[K] { + now := time.Now() + expiredEntries := make([]*limiterEntry[K], 0, l.expireBatchSize) + + l.mu.Lock() + defer l.mu.Unlock() + + for range l.expireBatchSize { + elem := l.evictionList.Back() + if elem == nil { + break + } + + entry := elem.Value.(*limiterEntry[K]) + if onlyExpired && now.Before(entry.expireTime) { + break + } + + if entry.debouncingCallback != nil { + expiredEntries = append(expiredEntries, entry) + } + l.evictionList.Remove(elem) + delete(l.entries, entry.key) + } + + return expiredEntries +} + +// runDebounce runs the debouncing callbacks for expired entries asynchronously. +func (l *Limiter[K]) runDebounce(entries []*limiterEntry[K]) { + l.wg.Add(1) + go func() { + defer l.wg.Done() + for _, entry := range entries { + entry.debouncingCallback() + } + }() +} + +// Close terminates the expiration loop and cleans up resources. +func (l *Limiter[K]) Close() { + close(l.closing) + + // Wait for all previous expiration job done. + l.wg.Wait() + + for l.evictionList.Len() > 0 { + expiredEntries := l.collectEntries(false) + l.runDebounce(expiredEntries) + } + + l.wg.Wait() +} diff --git a/pkg/limit/limiter_test.go b/pkg/limit/limiter_test.go new file mode 100644 index 000000000..3e779bb9e --- /dev/null +++ b/pkg/limit/limiter_test.go @@ -0,0 +1,421 @@ +/* + * Copyright 2025 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package limit_test provides unit tests for event timing control components. +package limit_test + +import ( + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/yorkie-team/yorkie/pkg/limit" +) + +// occurs encapsulates a slice of integers with a mutex for concurrent access. +type occurs struct { + array []int + mu sync.Mutex +} + +// add appends a value to the array. +func (o *occurs) add(e int) { + o.mu.Lock() + defer o.mu.Unlock() + o.array = append(o.array, e) +} + +// len returns the length of the array. +func (o *occurs) len() int { + o.mu.Lock() + defer o.mu.Unlock() + return len(o.array) +} + +// get returns the element at the specified index. +func (o *occurs) get(index int) int { + o.mu.Lock() + defer o.mu.Unlock() + return o.array[index] +} + +// TestThrottlerBehavior verifies the behavior of synchronous calls to the throttler. +// You can refer to the visualization in https://github.com/yorkie-team/yorkie/pull/1166. +func TestThrottlerBehavior(t *testing.T) { + const ( + expireBatchSize = 100 + expireInterval = 10 * time.Millisecond + throttleWindow = 100 * time.Millisecond + debouncingTime = 100 * time.Millisecond + executeTime = 5 * time.Millisecond + waitingTime = expireInterval + throttleWindow + debouncingTime + executeTime + ) + + // Test case: "e1" -> e1 occurs + t.Run("e1", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := &occurs{} + + e1 := func() { o.add(1) } + if lim.Allow("key", e1) { + e1() + } + // Immediately after execution, the callback should have been invoked. + assert.Equal(t, 1, o.get(0)) + // After waiting, no additional invocation should occur. + time.Sleep(waitingTime) + assert.Equal(t, 1, o.len()) + lim.Close() + }) + + // Test case: "e1 e2" -> e1 then e2 occurs + t.Run("e1 e2", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := &occurs{} + + e1 := func() { o.add(1) } + if lim.Allow("key", e1) { + e1() + } + // First callback is executed directly. + assert.Equal(t, 1, o.get(0)) + + e2 := func() { o.add(2) } + if lim.Allow("key", e2) { + e2() + } + + // At this point, only the immediate callback should have occurred. + assert.Equal(t, 1, o.len()) + time.Sleep(waitingTime) + + // After waiting, the deferred callback should be executed. + assert.Equal(t, 2, o.len()) + assert.Equal(t, 2, o.get(1)) + lim.Close() + }) + + // Test case: "e1 e2 e3" -> e1 immediately and e3 deferred + t.Run("e1 e2 e3", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := &occurs{} + + e1 := func() { o.add(1) } + if lim.Allow("key", e1) { + e1() + } + // First callback is executed immediately. + assert.Equal(t, 1, o.get(0)) + + e2 := func() { o.add(2) } + if lim.Allow("key", e2) { + e2() + } + e3 := func() { o.add(3) } + if lim.Allow("key", e3) { + e3() + } + + // Only the immediate callback should have been executed so far. + assert.Equal(t, 1, o.len()) + time.Sleep(waitingTime) + + // After waiting, the latest callback (e3) is executed. + assert.Equal(t, 2, o.len()) + assert.Equal(t, 3, o.get(1)) + lim.Close() + }) + + // Test case: "/ e1 e2 e3 / e4" -> e1 immediately then e4 immediately when allowed + t.Run("/ e1 e2 e3 / e4", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := &occurs{} + + e1 := func() { o.add(1) } + if lim.Allow("key", e1) { + e1() + } + // Immediate execution for the first callback. + assert.Equal(t, 1, o.get(0)) + + e2 := func() { o.add(2) } + if lim.Allow("key", e2) { + e2() + } + e3 := func() { o.add(3) } + if lim.Allow("key", e3) { + e3() + } + + // Still, only the immediate callback should have been executed. + assert.Equal(t, 1, o.len()) + + // Wait for part of the throttle window; deferred callbacks are not yet flushed. + time.Sleep(throttleWindow + debouncingTime/2) + assert.Equal(t, 1, o.len()) + + e4 := func() { o.add(4) } + if lim.Allow("key", e4) { + e4() + } + + // The new callback should now be executed immediately. + assert.Equal(t, 2, o.len()) + assert.Equal(t, 4, o.get(1)) + time.Sleep(waitingTime) + // No further callbacks should be executed after waiting. + assert.Equal(t, 2, o.len()) + lim.Close() + }) + + // Test case: "/ e1 e2 e3 / e4 e5" -> e1, then e4 immediately, then e5 deferred + t.Run("/ e1 e2 e3 / e4 e5", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := &occurs{} + + // e1 occurs directly. + e1 := func() { o.add(1) } + if lim.Allow("key", e1) { + e1() + } + assert.Equal(t, 1, o.get(0)) + + // e2 is saved. + e2 := func() { o.add(2) } + if lim.Allow("key", e2) { + e2() + } + // e3 replaces e2. + e3 := func() { o.add(3) } + if lim.Allow("key", e3) { + e3() + } + assert.Equal(t, 1, o.len()) + time.Sleep(throttleWindow + debouncingTime/2) + assert.Equal(t, 1, o.len()) + + // Before flushing e3, e4 occurs so e3 is skipped. + e4 := func() { o.add(4) } + if lim.Allow("key", e4) { + e4() + } + assert.Equal(t, 2, o.len()) + assert.Equal(t, 4, o.get(1)) + + // e5 meets limit so it is saved. + e5 := func() { o.add(5) } + if lim.Allow("key", e5) { + e5() + } + assert.Equal(t, 2, o.len()) + + // And flushed when it expires. + time.Sleep(waitingTime) + assert.Equal(t, 3, o.len()) + assert.Equal(t, 5, o.get(2)) + lim.Close() + }) +} + +// TestConcurrentExecution verifies the throttler behavior under concurrent execution scenarios. +func TestConcurrentExecution(t *testing.T) { + const ( + expireBatchSize = 100 + expireInterval = 10 * time.Millisecond + throttleWindow = 100 * time.Millisecond + debouncingTime = 100 * time.Millisecond + executeTime = 5 * time.Millisecond + waitingTime = expireInterval + throttleWindow + debouncingTime + executeTime + numExecute = 1000 + ) + + t.Run("Multiple Synchronous Calls with Trailing Debounce", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := occurs{} + callback := func() { o.add(1) } + + for range numExecute { + if lim.Allow("key", callback) { + callback() + } + } + assert.Equal(t, 1, o.len()) + + time.Sleep(waitingTime) + assert.Equal(t, 2, o.len()) + lim.Close() + assert.Equal(t, 2, o.len()) + }) + + t.Run("Concurrent Calls: Single Immediate and Trailing Execution", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := occurs{} + callback := func() { o.add(1) } + + wg := sync.WaitGroup{} + for range numExecute { + wg.Add(1) + go func() { + defer wg.Done() + if lim.Allow("key", callback) { + callback() + } + }() + } + wg.Wait() + assert.Equal(t, 1, o.len()) + + time.Sleep(waitingTime) + assert.Equal(t, 2, o.len()) + lim.Close() + assert.Equal(t, 2, o.len()) + }) + + t.Run("Concurrent Calls with Different Keys", func(t *testing.T) { + lim := limit.NewLimiter[int](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := occurs{ + array: make([]int, 0, numExecute*2), + } + callback := func() { o.add(1) } + + wg := sync.WaitGroup{} + for i := range numExecute { + i := i + wg.Add(1) + go func() { + defer wg.Done() + if lim.Allow(i, callback) { + callback() + } + }() + } + wg.Wait() + + time.Sleep(waitingTime) + assert.Equal(t, numExecute, o.len()) + lim.Close() + assert.Equal(t, numExecute, o.len()) + }) + + t.Run("Continuous Event Stream Throttling", func(t *testing.T) { + const ( + numWindows = 3 // Number of throttle windows. + ) + + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + + o := occurs{ + array: make([]int, 0, numWindows+1), + } + + // Continuously trigger events until the simulation ends. + for i := range numWindows { + callback := func() { o.add(i) } + for range numExecute { + if lim.Allow("key", callback) { + callback() + } + } + time.Sleep(throttleWindow) + } + assert.Equal(t, numWindows, o.len()) + time.Sleep(waitingTime) + assert.Equal(t, numWindows+1, o.len()) + + for i := range numWindows { + assert.Equal(t, i, o.get(i)) + } + }) +} + +// TestBatchExpiration verifies that the expiration loop processes expired entries in batches. +func TestBatchExpiration(t *testing.T) { + const ( + expireInterval = 100 * time.Millisecond + throttleWindow = 50 * time.Millisecond + debouncingTime = 50 * time.Millisecond + + expireBatchSize = 10 + batchNum = 3 + + totalKeys = expireBatchSize * batchNum // create more keys than one batch + ) + + t.Run("Process Expire Batch", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := occurs{ + array: make([]int, 0, totalKeys*2), + } + callback := func() { o.add(1) } + + // For each key: first call executes immediately, second call schedules a debounced callback. + for i := range totalKeys { + key := fmt.Sprintf("key-%d", i) + // Immediate execution. + if lim.Allow(key, callback) { + callback() + } + // Queue the debounced callback. + lim.Allow(key, callback) + } + + assert.Equal(t, totalKeys, o.len()) + time.Sleep(expireInterval / 2) + for i := range batchNum { + assert.Equal(t, totalKeys+expireBatchSize*i, o.len()) + time.Sleep(expireInterval) + } + assert.Equal(t, totalKeys+expireBatchSize*batchNum, o.len()) + lim.Close() + assert.Equal(t, totalKeys+expireBatchSize*batchNum, o.len()) + }) + + t.Run("Force Close Expired", func(t *testing.T) { + lim := limit.NewLimiter[string](expireBatchSize, expireInterval, throttleWindow, debouncingTime) + o := occurs{ + array: make([]int, 0, totalKeys*2), + } + callback := func() { o.add(1) } + + for i := range totalKeys { + key := fmt.Sprintf("key-%d", i) + // Immediate execution. + if lim.Allow(key, callback) { + callback() + } + // Queue the debounced callback. + lim.Allow(key, callback) + } + + assert.Equal(t, totalKeys, o.len()) + + done := make(chan struct{}) + go func() { + lim.Close() + done <- struct{}{} + }() + select { + case <-done: + assert.Equal(t, totalKeys+expireBatchSize*batchNum, o.len()) + case <-time.After(expireInterval): + assert.Equal(t, totalKeys+expireBatchSize*batchNum, o.len()) + assert.Fail(t, "close timeout") + } + }) +} diff --git a/pkg/webhook/client_test.go b/pkg/webhook/client_test.go index b0afa1a34..7bc48da98 100644 --- a/pkg/webhook/client_test.go +++ b/pkg/webhook/client_test.go @@ -2,12 +2,7 @@ package webhook_test import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" - "errors" - "fmt" "io" "net/http" "net/http/httptest" @@ -18,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/yorkie-team/yorkie/pkg/webhook" + "github.com/yorkie-team/yorkie/test/helper" ) // testRequest is a simple request type for demonstration. @@ -30,18 +26,6 @@ type testResponse struct { Greeting string `json:"greeting"` } -// verifySignature verifies that the HMAC signature in the header matches the expected value. -func verifySignature(signatureHeader, secret string, body []byte) error { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - expectedSig := hex.EncodeToString(mac.Sum(nil)) - expectedSigHeader := fmt.Sprintf("sha256=%s", expectedSig) - if !hmac.Equal([]byte(signatureHeader), []byte(expectedSigHeader)) { - return errors.New("signature validation failed") - } - return nil -} - // newHMACTestServer creates a new httptest.Server that verifies the HMAC signature. // It returns a valid JSON response if the signature is correct. func newHMACTestServer(t *testing.T, validSecret string, responseData testResponse) *httptest.Server { @@ -58,7 +42,7 @@ func newHMACTestServer(t *testing.T, validSecret string, responseData testRespon return } - if err := verifySignature(signatureHeader, validSecret, bodyBytes); err != nil { + if err := helper.VerifySignature(signatureHeader, validSecret, bodyBytes); err != nil { http.Error(w, "forbidden", http.StatusForbidden) return } diff --git a/server/backend/backend.go b/server/backend/backend.go index bc707d9c9..e80f7d5e6 100644 --- a/server/backend/backend.go +++ b/server/backend/backend.go @@ -27,7 +27,7 @@ import ( "github.com/yorkie-team/yorkie/api/types" "github.com/yorkie-team/yorkie/pkg/cache" pkgtypes "github.com/yorkie-team/yorkie/pkg/types" - "github.com/yorkie-team/yorkie/pkg/webhook" + pkgwebhook "github.com/yorkie-team/yorkie/pkg/webhook" "github.com/yorkie-team/yorkie/server/backend/background" "github.com/yorkie-team/yorkie/server/backend/database" memdb "github.com/yorkie-team/yorkie/server/backend/database/memory" @@ -36,6 +36,7 @@ import ( "github.com/yorkie-team/yorkie/server/backend/messagebroker" "github.com/yorkie-team/yorkie/server/backend/pubsub" "github.com/yorkie-team/yorkie/server/backend/sync" + "github.com/yorkie-team/yorkie/server/backend/webhook" "github.com/yorkie-team/yorkie/server/logging" "github.com/yorkie-team/yorkie/server/profiling/prometheus" ) @@ -51,10 +52,10 @@ type Backend struct { *types.AuthWebhookResponse, ]] // AuthWebhookClient is used to send auth webhook. - AuthWebhookClient *webhook.Client[types.AuthWebhookRequest, types.AuthWebhookResponse] + AuthWebhookClient *pkgwebhook.Client[types.AuthWebhookRequest, types.AuthWebhookResponse] - // EventWebhookClient is used to send event webhook - EventWebhookClient *webhook.Client[types.EventWebhookRequest, int] + // EventWebhookManager is used to send event webhook + EventWebhookManager *webhook.Manager // PubSub is used to publish/subscribe events to/from clients. PubSub *pubsub.PubSub @@ -97,8 +98,8 @@ func New( authWebhookCache := cache.NewLRUExpireCache[string, pkgtypes.Pair[int, *types.AuthWebhookResponse]]( conf.AuthWebhookCacheSize, ) - authWebhookClient := webhook.NewClient[types.AuthWebhookRequest, types.AuthWebhookResponse]( - webhook.Options{ + authWebhookClient := pkgwebhook.NewClient[types.AuthWebhookRequest, types.AuthWebhookResponse]( + pkgwebhook.Options{ MaxRetries: conf.AuthWebhookMaxRetries, MinWaitInterval: conf.ParseAuthWebhookMinWaitInterval(), MaxWaitInterval: conf.ParseAuthWebhookMaxWaitInterval(), @@ -106,14 +107,14 @@ func New( }, ) - eventWebhookClient := webhook.NewClient[types.EventWebhookRequest, int]( - webhook.Options{ + eventWebhookManger := webhook.NewManager(pkgwebhook.NewClient[types.EventWebhookRequest, int]( + pkgwebhook.Options{ MaxRetries: conf.EventWebhookMaxRetries, MinWaitInterval: conf.ParseEventWebhookMinWaitInterval(), MaxWaitInterval: conf.ParseEventWebhookMaxWaitInterval(), RequestTimeout: conf.ParseEventWebhookRequestTimeout(), }, - ) + )) // 03. Create pubsub, and locker. locker := sync.New() @@ -177,9 +178,9 @@ func New( return &Backend{ Config: conf, - AuthWebhookCache: authWebhookCache, - AuthWebhookClient: authWebhookClient, - EventWebhookClient: eventWebhookClient, + AuthWebhookCache: authWebhookCache, + AuthWebhookClient: authWebhookClient, + EventWebhookManager: eventWebhookManger, Locker: locker, PubSub: pubsub, @@ -210,6 +211,8 @@ func (b *Backend) Shutdown() error { b.Background.Close() + b.EventWebhookManager.Close() + if err := b.MsgBroker.Close(); err != nil { logging.DefaultLogger().Error(err) } diff --git a/server/backend/webhook/manager.go b/server/backend/webhook/manager.go new file mode 100644 index 000000000..314b3f21f --- /dev/null +++ b/server/backend/webhook/manager.go @@ -0,0 +1,102 @@ +/* + * Copyright 2025 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package webhook provides publishing events to project endpoint. +package webhook + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/yorkie-team/yorkie/api/types" + "github.com/yorkie-team/yorkie/pkg/limit" + "github.com/yorkie-team/yorkie/pkg/webhook" + "github.com/yorkie-team/yorkie/server/logging" +) + +const ( + // TODO(window9u): Consider making this parameter configurable via CLI. + expireInterval = 100 * time.Millisecond + throttleWindow = 1 * time.Second + debouncingTime = 1 * time.Second + expireBatchSize = 100 +) + +var ( + // ErrUnexpectedStatusCode is returned when the webhook returns an unexpected status code. + ErrUnexpectedStatusCode = errors.New("unexpected status code from webhook") +) + +// Manager manages sending webhook events with rate limiting. +type Manager struct { + limiter *limit.Limiter[types.EventRefKey] + webhookClient *webhook.Client[types.EventWebhookRequest, int] +} + +// NewManager creates a new instance of Manager with the provided webhook client. +func NewManager(cli *webhook.Client[types.EventWebhookRequest, int]) *Manager { + return &Manager{ + limiter: limit.NewLimiter[types.EventRefKey](expireBatchSize, expireInterval, throttleWindow, debouncingTime), + webhookClient: cli, + } +} + +// Send dispatches a webhook event for the specified document and event reference key. +// It uses rate limiting to debounce multiple events within a short period. +func (m *Manager) Send(ctx context.Context, info types.EventWebhookInfo) error { + callback := func() { + if err := SendWebhook(ctx, m.webhookClient, info.EventRefKey.EventWebhookType, info.Attribute); err != nil { + logging.From(ctx).Error(err) + } + } + + // If allowed immediately, invoke the callback. + if allowed := m.limiter.Allow(info.EventRefKey, callback); allowed { + return SendWebhook(ctx, m.webhookClient, info.EventRefKey.EventWebhookType, info.Attribute) + } + return nil +} + +// Close closes the event webhook manager. This will wait for flushing remain debouncing events +func (m *Manager) Close() { + m.limiter.Close() +} + +// SendWebhook sends the webhook event using the provided client. +// It builds the request body and checks for a successful HTTP response. +func SendWebhook( + ctx context.Context, + cli *webhook.Client[types.EventWebhookRequest, int], + event types.EventWebhookType, + attr types.WebhookAttribute, +) error { + body, err := types.NewRequestBody(attr.DocKey, event) + if err != nil { + return fmt.Errorf("create webhook request body: %w", err) + } + + _, status, err := cli.Send(ctx, attr.URL, attr.SigningKey, body) + if err != nil { + return fmt.Errorf("send webhook event: %w", err) + } + if status != http.StatusOK { + return fmt.Errorf("webhook returned status %d: %w", status, ErrUnexpectedStatusCode) + } + return nil +} diff --git a/server/packs/packs.go b/server/packs/packs.go index c891fc9e6..7ca19c5c7 100644 --- a/server/packs/packs.go +++ b/server/packs/packs.go @@ -37,7 +37,6 @@ import ( "github.com/yorkie-team/yorkie/server/backend/database" "github.com/yorkie-team/yorkie/server/backend/sync" "github.com/yorkie-team/yorkie/server/logging" - "github.com/yorkie-team/yorkie/server/webhook" ) // PushPullKey creates a new sync.Key of PushPull for the given document. @@ -199,14 +198,15 @@ func PushPull( }, ) - if reqPack.OperationsLen() > 0 { - if err := webhook.SendEvent( - ctx, - be, - project, + if reqPack.OperationsLen() > 0 && project.RequireEventWebhook(events.DocRootChangedEvent.WebhookType()) { + info := types.NewEventWebhookInfo( + docRefKey, + events.DocRootChangedEvent.WebhookType(), + project.SecretKey, + project.EventWebhookURL, docInfo.Key.String(), - events.DocRootChangedEvent, - ); err != nil { + ) + if err := be.EventWebhookManager.Send(ctx, info); err != nil { logging.From(ctx).Error(err) return } diff --git a/server/webhook/events.go b/server/webhook/events.go deleted file mode 100644 index 76c007373..000000000 --- a/server/webhook/events.go +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2025 The Yorkie Authors. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Package webhook provides publishing events to project endpoint. -package webhook - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - gotime "time" - - "github.com/yorkie-team/yorkie/api/types" - "github.com/yorkie-team/yorkie/api/types/events" - "github.com/yorkie-team/yorkie/server/backend" -) - -var ( - // ErrUnexpectedStatusCode is returned when the webhook returns an unexpected status code. - ErrUnexpectedStatusCode = errors.New("unexpected status code from webhook") -) - -// SendEvent sends an event to the project's event webhook endpoint. -func SendEvent( - ctx context.Context, - be *backend.Backend, - prj *types.Project, - docKey string, - eventType events.DocEventType, -) error { - webhookType := eventType.WebhookType() - if webhookType == "" { - return fmt.Errorf("invalid event webhook type: %s", eventType) - } - - if !prj.RequireEventWebhook(webhookType) { - return nil - } - - body, err := buildRequestBody(docKey, webhookType) - if err != nil { - return fmt.Errorf("marshal event webhook request: %w", err) - } - - _, status, err := be.EventWebhookClient.Send( - ctx, - prj.EventWebhookURL, - prj.SecretKey, - body, - ) - if err != nil { - return fmt.Errorf("send event webhook: %w", err) - } - if status != http.StatusOK { - return fmt.Errorf("send event webhook %d: %w", status, ErrUnexpectedStatusCode) - } - - return nil -} - -// buildRequestBody builds the request body for the event webhook. -func buildRequestBody(docKey string, webhookType types.EventWebhookType) ([]byte, error) { - req := types.EventWebhookRequest{ - Type: webhookType, - Attributes: types.EventWebhookAttribute{ - Key: docKey, - IssuedAt: gotime.Now().UTC().Format("2006-01-02T15:04:05.000Z"), - }, - } - - body, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal event webhook request: %w", err) - } - - return body, nil -} diff --git a/test/bench/webhook_bench_test.go b/test/bench/webhook_bench_test.go new file mode 100644 index 000000000..a69e9178c --- /dev/null +++ b/test/bench/webhook_bench_test.go @@ -0,0 +1,164 @@ +/* + * Copyright 2025 The Yorkie Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package bench + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/yorkie-team/yorkie/api/types" + pkgwebhook "github.com/yorkie-team/yorkie/pkg/webhook" + "github.com/yorkie-team/yorkie/server/backend/webhook" +) + +// setupWebhookServer simulates an HTTP server for the benchmark. +func setupWebhookServer(t *testing.B, count int) []*httptest.Server { + servers := make([]*httptest.Server, 0, count) + for range count { + servers = append(servers, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.NotEmpty(t, r.Header.Get("X-Signature-256")) + w.WriteHeader(http.StatusOK) + }))) + } + return servers +} + +func BenchmarkWebhook(b *testing.B) { + benches := []struct { + endpointNum int + webhookNum int + delay time.Duration + }{ + {endpointNum: 10, webhookNum: 10, delay: 0}, + {endpointNum: 10, webhookNum: 100, delay: 0}, + {endpointNum: 100, webhookNum: 10, delay: 0}, + {endpointNum: 100, webhookNum: 100, delay: 0}, + + {endpointNum: 10, webhookNum: 10, delay: 10 * time.Millisecond}, + {endpointNum: 10, webhookNum: 100, delay: 10 * time.Millisecond}, + {endpointNum: 100, webhookNum: 10, delay: 10 * time.Millisecond}, + {endpointNum: 100, webhookNum: 100, delay: 10 * time.Millisecond}, + } + + for _, bench := range benches { + tName := fmt.Sprintf( + "Send %d Webhooks to %d Endpoints with delay %s", + bench.webhookNum, + bench.endpointNum, + bench.delay.String(), + ) + b.Run(tName, func(b *testing.B) { + benchmarkSendWebhook(b, bench.webhookNum, bench.endpointNum, bench.delay) + }) + + tName = fmt.Sprintf( + "Send %d Webhooks to %d Endpoints limiter with delay %s", + bench.webhookNum, + bench.endpointNum, + bench.delay.String(), + ) + b.Run(tName, func(b *testing.B) { + benchmarkSendWebhookWithLimits(b, bench.webhookNum, bench.endpointNum, bench.delay) + }) + } +} + +func benchmarkSendWebhook(b *testing.B, webhookNum, endpointNum int, delay time.Duration) { + b.ReportAllocs() + const ( + docKey = "doc-key" + signingKey = "sign-key" + ) + endpoints := setupWebhookServer(b, endpointNum) + cli := pkgwebhook.NewClient[types.EventWebhookRequest, int]( + pkgwebhook.Options{ + MaxRetries: 0, + MinWaitInterval: 100 * time.Millisecond, + MaxWaitInterval: 100 * time.Millisecond, + RequestTimeout: 100 * time.Millisecond, + }, + ) + for range b.N { + for range webhookNum { + for i := range endpointNum { + err := webhook.SendWebhook( + context.Background(), + cli, + types.DocRootChanged, + types.WebhookAttribute{ + DocKey: docKey, + SigningKey: signingKey, + URL: endpoints[i].URL, + }, + ) + assert.NoError(b, err) + } + if delay > 0 { + time.Sleep(delay) + } + } + } +} + +func benchmarkSendWebhookWithLimits(b *testing.B, webhookNum, endpointNum int, delay time.Duration) { + b.ReportAllocs() + const ( + docKey = "doc-key" + signingKey = "sign-key" + ) + docRefKey := types.DocRefKey{ + DocID: "doc-id", + ProjectID: "prj-id", + } + + endpoints := setupWebhookServer(b, endpointNum) + cli := pkgwebhook.NewClient[types.EventWebhookRequest, int]( + pkgwebhook.Options{ + MaxRetries: 0, + MinWaitInterval: 100 * time.Millisecond, + MaxWaitInterval: 100 * time.Millisecond, + RequestTimeout: 100 * time.Millisecond, + }, + ) + manager := webhook.NewManager(cli) + for range b.N { + for range webhookNum { + for i := range endpointNum { + err := manager.Send( + context.Background(), + types.NewEventWebhookInfo( + docRefKey, + types.DocRootChanged, + signingKey, + endpoints[i].URL, + docKey, + ), + ) + assert.NoError(b, err) + } + if delay > 0 { + time.Sleep(delay) + } + } + } +} diff --git a/test/helper/helper.go b/test/helper/helper.go index 00d5c45ef..8218e2b84 100644 --- a/test/helper/helper.go +++ b/test/helper/helper.go @@ -19,6 +19,10 @@ package helper import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" "fmt" "log" "net" @@ -578,3 +582,15 @@ func CleanupClients(b *testing.B, clients []*client.Client) { assert.NoError(b, c.Close()) } } + +// VerifySignature verifies that the HMAC signature in the header matches the expected value. +func VerifySignature(signatureHeader, secret string, body []byte) error { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + expectedSigHeader := fmt.Sprintf("sha256=%s", expectedSig) + if !hmac.Equal([]byte(signatureHeader), []byte(expectedSigHeader)) { + return errors.New("signature validation failed") + } + return nil +} diff --git a/test/integration/event_webhook_test.go b/test/integration/event_webhook_test.go index 50e932727..b8a27981a 100644 --- a/test/integration/event_webhook_test.go +++ b/test/integration/event_webhook_test.go @@ -20,12 +20,7 @@ package integration import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" gojson "encoding/json" - "errors" - "fmt" "io" "net/http" "net/http/httptest" @@ -45,17 +40,6 @@ import ( "github.com/yorkie-team/yorkie/test/helper" ) -func verifySignature(signatureHeader, secret string, body []byte) error { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - expectedSig := hex.EncodeToString(mac.Sum(nil)) - expectedSigHeader := fmt.Sprintf("sha256=%s", expectedSig) - if !hmac.Equal([]byte(signatureHeader), []byte(expectedSigHeader)) { - return errors.New("signature validation failed") - } - return nil -} - func newWebhookServer(t *testing.T, secretKey, docKey string) (*httptest.Server, *int32) { var reqCnt int32 @@ -65,7 +49,7 @@ func newWebhookServer(t *testing.T, secretKey, docKey string) (*httptest.Server, assert.NotZero(t, len(signatureHeader)) body, err := io.ReadAll(r.Body) assert.NoError(t, err) - assert.NoError(t, verifySignature(signatureHeader, secretKey, body)) + assert.NoError(t, helper.VerifySignature(signatureHeader, secretKey, body)) req := &types.EventWebhookRequest{} assert.NoError(t, gojson.Unmarshal(body, req)) @@ -89,10 +73,6 @@ func newYorkieServer(t *testing.T, projectCacheTTL string) *server.Yorkie { assert.NoError(t, err) assert.NoError(t, svr.Start()) - t.Cleanup(func() { - assert.NoError(t, svr.Shutdown(true)) - }) - return svr } @@ -100,18 +80,18 @@ func newActivatedClient(t *testing.T, ctx context.Context, addr, publicKey strin cli, err := client.Dial(addr, client.WithAPIKey(publicKey)) assert.NoError(t, err) assert.NoError(t, cli.Activate(ctx)) - t.Cleanup(func() { - assert.NoError(t, cli.Deactivate(ctx)) - assert.NoError(t, cli.Close()) - }) return cli } func TestRegisterEventWebhook(t *testing.T) { + const ( + projectCacheTTL = 1 * time.Millisecond + waitWebhookReceived = 10 * time.Millisecond + ) + ctx := context.Background() // Set up yorkie server - projectCacheTTL := 1 * time.Millisecond svr := newYorkieServer(t, projectCacheTTL.String()) // Set up project @@ -130,9 +110,7 @@ func TestRegisterEventWebhook(t *testing.T) { "counter": json.NewCounter(0, crdt.LongCnt), }))) - waitWebhookReceived := 10 * time.Millisecond - - t.Run("register event webhook test", func(t *testing.T) { + t.Run("register and unregister event webhook test", func(t *testing.T) { // 01. Register event webhook prj, err := adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ EventWebhookURL: &userServer.URL, @@ -154,11 +132,9 @@ func TestRegisterEventWebhook(t *testing.T) { assert.NoError(t, cli.Sync(ctx)) time.Sleep(waitWebhookReceived) assert.Equal(t, prev+1, atomic.LoadInt32(getReqCnt)) - }) - t.Run("unregister event webhook test", func(t *testing.T) { - // 01. Unregister event webhook - prj, err := adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ + // 04. Unregister event webhook + prj, err = adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ EventWebhookURL: &userServer.URL, EventWebhookEvents: &[]string{}, }) @@ -166,72 +142,126 @@ func TestRegisterEventWebhook(t *testing.T) { assert.Equal(t, userServer.URL, prj.EventWebhookURL) assert.Equal(t, 0, len(prj.EventWebhookEvents)) - // 02. Wait project cache expired + // 05. Wait project cache expired time.Sleep(projectCacheTTL) - // 03. Check webhook doesn't trigger - prev := atomic.LoadInt32(getReqCnt) + // 06. Check webhook doesn't trigger + prev = atomic.LoadInt32(getReqCnt) assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { root.GetCounter("counter").Increase(1) return nil })) assert.NoError(t, cli.Sync(ctx)) - // 04. Wait webhook received - time.Sleep(waitWebhookReceived) + // 07. Wait webhook received + assert.NoError(t, svr.Shutdown(true)) assert.Equal(t, prev, atomic.LoadInt32(getReqCnt)) }) } func TestDocRootChangedEventWebhook(t *testing.T) { - ctx := context.Background() + const waitWebhookReceived = 10 * time.Millisecond + t.Run("root element changed test", func(t *testing.T) { + ctx := context.Background() - svr := newYorkieServer(t, "default") - adminCli := helper.CreateAdminCli(t, svr.RPCAddr()) + svr := newYorkieServer(t, "default") + adminCli := helper.CreateAdminCli(t, svr.RPCAddr()) - project, err := adminCli.CreateProject(ctx, "doc-root-changed-event-webhook") - assert.NoError(t, err) + project, err := adminCli.CreateProject(ctx, "doc-root-changed-event-webhook") + assert.NoError(t, err) - doc := document.New(helper.TestDocKey(t)) - userServer, getReqCnt := newWebhookServer(t, project.SecretKey, doc.Key().String()) + doc := document.New(helper.TestDocKey(t)) + userServer, getReqCnt := newWebhookServer(t, project.SecretKey, doc.Key().String()) - project.EventWebhookURL = userServer.URL - _, err = adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ - EventWebhookURL: &project.EventWebhookURL, - EventWebhookEvents: &[]string{string(types.DocRootChanged)}, - }) - assert.NoError(t, err) + project.EventWebhookURL = userServer.URL + _, err = adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ + EventWebhookURL: &project.EventWebhookURL, + EventWebhookEvents: &[]string{string(types.DocRootChanged)}, + }) + assert.NoError(t, err) - cli := newActivatedClient(t, ctx, svr.RPCAddr(), project.PublicKey) + cli := newActivatedClient(t, ctx, svr.RPCAddr(), project.PublicKey) - assert.NoError(t, cli.Attach(ctx, doc, client.WithInitialRoot(map[string]any{ - "counter": json.NewCounter(0, crdt.LongCnt), - }))) + assert.NoError(t, cli.Attach(ctx, doc, client.WithInitialRoot(map[string]any{ + "counter": json.NewCounter(0, crdt.LongCnt), + }))) + assert.NoError(t, cli.Sync(ctx)) + time.Sleep(waitWebhookReceived) - waitWebhookReceived := 10 * time.Millisecond - t.Run("root element changed test", func(t *testing.T) { prev := atomic.LoadInt32(getReqCnt) assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { root.GetCounter("counter").Increase(1) return nil })) assert.NoError(t, cli.Sync(ctx)) - time.Sleep(waitWebhookReceived) + assert.NoError(t, svr.Shutdown(true)) assert.Equal(t, prev+1, atomic.LoadInt32(getReqCnt)) }) t.Run("presence changed test", func(t *testing.T) { + ctx := context.Background() + + svr := newYorkieServer(t, "default") + adminCli := helper.CreateAdminCli(t, svr.RPCAddr()) + + project, err := adminCli.CreateProject(ctx, "presence-changed-event-webhook") + assert.NoError(t, err) + + doc := document.New(helper.TestDocKey(t)) + userServer, getReqCnt := newWebhookServer(t, project.SecretKey, doc.Key().String()) + + project.EventWebhookURL = userServer.URL + _, err = adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ + EventWebhookURL: &project.EventWebhookURL, + EventWebhookEvents: &[]string{string(types.DocRootChanged)}, + }) + assert.NoError(t, err) + + cli := newActivatedClient(t, ctx, svr.RPCAddr(), project.PublicKey) + + assert.NoError(t, cli.Attach(ctx, doc, client.WithInitialRoot(map[string]any{ + "counter": json.NewCounter(0, crdt.LongCnt), + }))) + assert.NoError(t, cli.Sync(ctx)) + time.Sleep(waitWebhookReceived) + prev := atomic.LoadInt32(getReqCnt) assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { p.Set("update", "2") return nil })) assert.NoError(t, cli.Sync(ctx)) - time.Sleep(waitWebhookReceived) + assert.NoError(t, svr.Shutdown(true)) assert.Equal(t, prev, atomic.LoadInt32(getReqCnt)) }) t.Run("root element and presence changed test", func(t *testing.T) { + ctx := context.Background() + + svr := newYorkieServer(t, "default") + adminCli := helper.CreateAdminCli(t, svr.RPCAddr()) + + project, err := adminCli.CreateProject(ctx, "root-presence-changed-event") + assert.NoError(t, err) + + doc := document.New(helper.TestDocKey(t)) + userServer, getReqCnt := newWebhookServer(t, project.SecretKey, doc.Key().String()) + + project.EventWebhookURL = userServer.URL + _, err = adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ + EventWebhookURL: &project.EventWebhookURL, + EventWebhookEvents: &[]string{string(types.DocRootChanged)}, + }) + assert.NoError(t, err) + + cli := newActivatedClient(t, ctx, svr.RPCAddr(), project.PublicKey) + + assert.NoError(t, cli.Attach(ctx, doc, client.WithInitialRoot(map[string]any{ + "counter": json.NewCounter(0, crdt.LongCnt), + }))) + assert.NoError(t, cli.Sync(ctx)) + time.Sleep(waitWebhookReceived) + prev := atomic.LoadInt32(getReqCnt) assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { p.Set("update", "3") @@ -239,19 +269,31 @@ func TestDocRootChangedEventWebhook(t *testing.T) { return nil })) assert.NoError(t, cli.Sync(ctx)) - time.Sleep(waitWebhookReceived) + assert.NoError(t, svr.Shutdown(true)) assert.Equal(t, prev+1, atomic.LoadInt32(getReqCnt)) }) } -func TestEventWebhookCache(t *testing.T) { +func TestEventWebhookThrottling(t *testing.T) { + const ( + webhookThrottleWindow = 1 * time.Second + debouncingTime = 1 * time.Second + expirationInterval = 100 * time.Millisecond + waitWebhookReceived = 10 * time.Millisecond + + numWindows = 2 + eventPerWindow = 30 + + testDuration = webhookThrottleWindow * time.Duration(numWindows) + eventInterval = webhookThrottleWindow / eventPerWindow + ) + ctx := context.Background() - webhookCacheTTL := 10 * time.Millisecond svr := newYorkieServer(t, "default") adminCli := helper.CreateAdminCli(t, svr.RPCAddr()) - project, err := adminCli.CreateProject(ctx, "event-webhook-cache-webhook") + project, err := adminCli.CreateProject(ctx, "event-webhook-throttle-webhook") assert.NoError(t, err) doc := document.New(helper.TestDocKey(t)) @@ -267,34 +309,106 @@ func TestEventWebhookCache(t *testing.T) { "counter": json.NewCounter(0, crdt.LongCnt), }))) - waitWebhookReceived := 20 * time.Millisecond - t.Run("throttling event test", func(t *testing.T) { - t.Skip("remove this after implement advanced event timing control") - - expectedUpdates := 5 - testDuration := webhookCacheTTL * time.Duration(expectedUpdates) - interval := webhookCacheTTL / 10 - - ticker := time.NewTicker(interval) + t.Run("throttling Event Test", func(t *testing.T) { + ticker := time.NewTicker(eventInterval) defer ticker.Stop() timeCtx, cancel := context.WithTimeout(ctx, testDuration) defer cancel() - prevCnt := atomic.LoadInt32(getReqCnt) + initialReqCount := atomic.LoadInt32(getReqCnt) + // Trigger document updates repeatedly. for { select { case <-ticker.C: - assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { + // Update the document: increase the counter. + err := doc.Update(func(root *json.Object, p *presence.Presence) error { root.GetCounter("counter").Increase(1) return nil - })) + }) + assert.NoError(t, err) assert.NoError(t, cli.Sync(ctx)) case <-timeCtx.Done(): + // Wait briefly to allow any pending webhook events to be received. time.Sleep(waitWebhookReceived) - assert.Equal(t, prevCnt+int32(expectedUpdates), atomic.LoadInt32(getReqCnt)) + // Expect the request count to have increased by the expected number of updates. + assert.Equal(t, initialReqCount+int32(numWindows), atomic.LoadInt32(getReqCnt)) + // Expect the trailing event webhook for eventual consistency. + time.Sleep(webhookThrottleWindow + debouncingTime + expirationInterval) + assert.Equal(t, initialReqCount+int32(numWindows+1), atomic.LoadInt32(getReqCnt)) + assert.NoError(t, svr.Shutdown(true)) + assert.Equal(t, initialReqCount+int32(numWindows+1), atomic.LoadInt32(getReqCnt)) return } } }) } + +func TestCloseEventManager(t *testing.T) { + const ( + webhookThrottleWindow = 1 * time.Second + debouncingTime = 1 * time.Second + expirationInterval = 100 * time.Millisecond + waitWebhookReceived = 10 * time.Millisecond + ) + + ctx := context.Background() + + svr, err := server.New(helper.TestConfig()) + assert.NoError(t, err) + assert.NoError(t, svr.Start()) + adminCli := helper.CreateAdminCli(t, svr.RPCAddr()) + + project, err := adminCli.CreateProject(ctx, "close-event-webhook-webhook") + assert.NoError(t, err) + + doc := document.New(helper.TestDocKey(t)) + userServer, getReqCnt := newWebhookServer(t, project.SecretKey, doc.Key().String()) + _, err = adminCli.UpdateProject(ctx, project.ID.String(), &types.UpdatableProjectFields{ + EventWebhookURL: &userServer.URL, + EventWebhookEvents: &[]string{string(types.DocRootChanged)}, + }) + assert.NoError(t, err) + + cli, err := client.Dial(svr.RPCAddr(), client.WithAPIKey(project.PublicKey)) + assert.NoError(t, err) + assert.NoError(t, cli.Activate(ctx)) + assert.NoError(t, cli.Attach(ctx, doc, client.WithInitialRoot(map[string]any{ + "counter": json.NewCounter(0, crdt.LongCnt), + }))) + + t.Run("Force flush event when server shutdown Test", func(t *testing.T) { + // this triggers webhook directly. + prev := atomic.LoadInt32(getReqCnt) + assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { + root.GetCounter("counter").Increase(1) + return nil + })) + assert.NoError(t, cli.Sync(ctx)) + time.Sleep(waitWebhookReceived) + assert.Equal(t, prev+1, atomic.LoadInt32(getReqCnt)) + + // this is queued and will be flushed by closing server + prev = atomic.LoadInt32(getReqCnt) + assert.NoError(t, doc.Update(func(root *json.Object, p *presence.Presence) error { + root.GetCounter("counter").Increase(1) + return nil + })) + assert.NoError(t, cli.Sync(ctx)) + assert.Equal(t, prev, atomic.LoadInt32(getReqCnt)) + + done := make(chan struct{}) + go func() { + assert.NoError(t, svr.Shutdown(true)) + done <- struct{}{} + }() + + select { + case <-done: + assert.Equal(t, prev+1, atomic.LoadInt32(getReqCnt)) + case <-time.After(webhookThrottleWindow + debouncingTime + expirationInterval): + assert.Equal(t, prev+1, atomic.LoadInt32(getReqCnt)) + assert.Fail(t, "closing timeout") + } + }) +}