A set of additional outbound ActivityPub outbound delivery optimizations (#4750)

* fix: remove optimistic UI updates in external actions to prevent race condition (#4711)

When adding/deleting external actions, the UI would sometimes show
duplicated or cloned rows due to a race condition between:
1. The optimistic setActions() call updating local state
2. The save() callback updating externalActions in context
3. The useEffect syncing actions from externalActions

This fix removes the optimistic updates and lets the server response
be the single source of truth, updating the UI only after the context
is updated via the save() success callback.

Fixes #4347

* fix(ap): additional outbound ActivityPub delivery optimizations

- check circuit breaker before signing any messages or any other work
- create outbound messages in batches and not all at once
- add small delay between outbound messages to spread it out a bit

* fix(ap): increase ap queue buffer size, reduce outbound http client timeout

* fix(ap): handle malformed inbox URIs

* fix(ap): batch outbox based on work performed not index

* feat(ap): add retryable activitypub outbound delivery client

---------

Co-authored-by: John Costa <jcosta@execonline.com>
This commit is contained in:
Gabe Kangas
2026-01-21 21:00:18 -08:00
committed by GitHub
co-authored by John Costa
parent 9441e6d120
commit 23ce430e5b
7 changed files with 161 additions and 32 deletions
+18 -12
View File
@@ -21,7 +21,7 @@ func TestCircuitBreaker(t *testing.T) {
testDomain := "failing.example.com"
// Initially, domain should not be skipped
if shouldSkipDomain(testDomain) {
if ShouldSkipDomain(testDomain) {
t.Error("Domain should not be skipped initially")
}
@@ -31,13 +31,13 @@ func TestCircuitBreaker(t *testing.T) {
recordDomainFailure(testDomain)
// Domain should now be skipped
if !shouldSkipDomain(testDomain) {
if !ShouldSkipDomain(testDomain) {
t.Error("Domain should be skipped after failures")
}
// After successful delivery, domain should be reset
resetDomainFailure(testDomain)
if shouldSkipDomain(testDomain) {
if ShouldSkipDomain(testDomain) {
t.Error("Domain should not be skipped after reset")
}
}
@@ -54,8 +54,8 @@ func TestHTTPTimeouts(t *testing.T) {
t.Error("HTTP client should be initialized")
}
if httpClient.Timeout != 15*time.Second {
t.Error("HTTP client should have 15 second timeout")
if httpClient.Timeout != 8*time.Second {
t.Errorf("HTTP client should have 8 second timeout, got %v", httpClient.Timeout)
}
}
@@ -64,11 +64,17 @@ func TestWorkerPoolSizing(t *testing.T) {
resetCircuitBreakerForTesting()
defer resetCircuitBreakerForTesting() // Clean up after test
// Test the queue sizing
// Test that queue buffer is at least the minimum (500) even for small worker pools
InitOutboundWorkerPool(5)
if cap(queue) != 5 {
t.Errorf("Queue capacity should be 5, got %d", cap(queue))
if cap(queue) < 500 {
t.Errorf("Queue capacity should be at least 500, got %d", cap(queue))
}
// Test that larger worker pools get proportionally larger buffers
InitOutboundWorkerPool(100)
if cap(queue) != 1000 {
t.Errorf("Queue capacity should be 1000 for 100 workers, got %d", cap(queue))
}
}
@@ -102,7 +108,7 @@ func TestCircuitBreakerIsolation(t *testing.T) {
domain2 := "test2.example.com"
// Neither domain should be blocked initially
if shouldSkipDomain(domain1) || shouldSkipDomain(domain2) {
if ShouldSkipDomain(domain1) || ShouldSkipDomain(domain2) {
t.Error("Domains should not be blocked initially")
}
@@ -112,16 +118,16 @@ func TestCircuitBreakerIsolation(t *testing.T) {
recordDomainFailure(domain1)
// Only domain1 should be blocked
if !shouldSkipDomain(domain1) {
if !ShouldSkipDomain(domain1) {
t.Error("Domain1 should be blocked after failures")
}
if shouldSkipDomain(domain2) {
if ShouldSkipDomain(domain2) {
t.Error("Domain2 should not be blocked")
}
// Reset and verify clean state
resetCircuitBreakerForTesting()
if shouldSkipDomain(domain1) || shouldSkipDomain(domain2) {
if ShouldSkipDomain(domain1) || ShouldSkipDomain(domain2) {
t.Error("Both domains should be unblocked after reset")
}
}