Skip to content

Commit 1e1b3f6

Browse files
committed
fix: stop the streaming queue shrinking with the pool on small instances
Tying queue capacity to max-threads made sense at Cloud's 32 threads and was punishing everywhere else. A default self-hosted install derives 3 streaming threads from Hikari's default pool of 10, which gave it a six-request admission window — and import-progress streams hold a thread for the whole import, so three concurrent imports made every export and machine-translation suggestion fail with 503 where they previously queued and completed. A burst is the same size regardless of how many threads drain it, so the queue now floors at 50 and only follows the thread count above that. Cloud is unaffected (it pins 32), and the 180s async timeout still bounds how long anything can wait — answered as 503 rather than left hanging, which is what this change fixed earlier. The capacity report now includes the queue capacity, which is the number that decides whether a burst is absorbed or rejected, and counts the two serial pools it also creates. The serial pools' keep-alive constant is no longer named after websockets now that automations use it too.
1 parent 0ed9b76 commit 1e1b3f6

7 files changed

Lines changed: 48 additions & 21 deletions

File tree

backend/app/src/main/kotlin/io/tolgee/configuration/AsyncCapacityReporter.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class AsyncCapacityReporter(
1515
@EventListener(ApplicationReadyEvent::class)
1616
fun report() {
1717
val streaming = asyncExecutorFactory.streamingMaxThreads
18+
val streamingQueue = asyncExecutorFactory.streamingQueueCapacity
1819
val background = asyncExecutorFactory.backgroundMaxThreads
1920
val batch = tolgeeProperties.batch.concurrency
2021
val connectionPoolSize = asyncExecutorFactory.connectionPoolSize
@@ -30,11 +31,11 @@ class AsyncCapacityReporter(
3031
}
3132

3233
logger.info(
33-
"Async capacity: $streaming streaming threads, $background background threads, " +
34-
"$batch batch jobs, $connectionPoolSize database connections.",
34+
"Async capacity: $streaming streaming threads (queue $streamingQueue), $background background " +
35+
"threads, $SERIAL_POOLS serial pools, $batch batch jobs, $connectionPoolSize database connections.",
3536
)
3637

37-
val reserved = streaming + background + batch
38+
val reserved = streaming + background + batch + SERIAL_POOLS
3839
if (reserved <= connectionPoolSize - minimumSyncReserve(connectionPoolSize)) return
3940

4041
logger.warn(
@@ -56,5 +57,8 @@ class AsyncCapacityReporter(
5657

5758
companion object {
5859
const val SYNC_RESERVE_DIVISOR = 4
60+
61+
/** The websocket and automation executors, one thread each. */
62+
const val SERIAL_POOLS = 2
5963
}
6064
}

backend/app/src/main/kotlin/io/tolgee/configuration/AsyncExecutorFactory.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class AsyncExecutorFactory(
2727
get() {
2828
val configured = tolgeeProperties.async.streaming.queueCapacity
2929
if (configured >= 0) return configured
30-
return streamingMaxThreads
30+
return maxOf(MIN_QUEUE_CAPACITY, streamingMaxThreads)
3131
}
3232

3333
val backgroundMaxThreads: Int
@@ -80,6 +80,9 @@ class AsyncExecutorFactory(
8080
const val STREAMING_POOL_DIVISOR = 3
8181
const val BACKGROUND_POOL_DIVISOR = 6
8282
const val FALLBACK_CONNECTION_POOL_SIZE = 10
83+
84+
/** A burst is a burst regardless of how many threads drain it; 3 threads still deserve a buffer. */
85+
const val MIN_QUEUE_CAPACITY = 50
8386
const val SHUTDOWN_DRAIN_SECONDS = 20
8487

8588
const val UNBOUNDED_QUEUE = Int.MAX_VALUE

backend/app/src/main/kotlin/io/tolgee/configuration/AsyncMethodConfiguration.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class AsyncMethodConfiguration(
4444
threadNamePrefix = AsyncExecutorFactory.WEBSOCKET_THREAD_NAME_PREFIX,
4545
maxThreads = 1,
4646
queueCapacity = AsyncExecutorFactory.UNBOUNDED_QUEUE,
47-
keepAliveSeconds = WEBSOCKET_KEEP_ALIVE_SECONDS,
47+
keepAliveSeconds = SERIAL_POOL_KEEP_ALIVE_SECONDS,
4848
)
4949

5050
/**
@@ -58,13 +58,13 @@ class AsyncMethodConfiguration(
5858
threadNamePrefix = AsyncExecutorFactory.AUTOMATION_THREAD_NAME_PREFIX,
5959
maxThreads = 1,
6060
queueCapacity = AsyncExecutorFactory.UNBOUNDED_QUEUE,
61-
keepAliveSeconds = WEBSOCKET_KEEP_ALIVE_SECONDS,
61+
keepAliveSeconds = SERIAL_POOL_KEEP_ALIVE_SECONDS,
6262
)
6363

6464
companion object {
6565
const val BACKGROUND_EXECUTOR_BEAN_NAME = "backgroundAsyncExecutor"
6666
const val WEBSOCKET_EXECUTOR_BEAN_NAME = "websocketAsyncExecutor"
6767
const val AUTOMATION_EXECUTOR_BEAN_NAME = "automationAsyncExecutor"
68-
const val WEBSOCKET_KEEP_ALIVE_SECONDS = 60
68+
const val SERIAL_POOL_KEEP_ALIVE_SECONDS = 60
6969
}
7070
}

backend/app/src/test/kotlin/io/tolgee/configuration/AsyncCapacityReporterTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ class AsyncCapacityReporterTest {
3939

4040
@Test
4141
fun `sits exactly on the boundary without warning, and warns one job past it`() {
42-
report(connectionPoolSize = 60, batchConcurrency = 15).warnings.assert.isEmpty()
43-
report(connectionPoolSize = 60, batchConcurrency = 16).warnings.assert.isNotEmpty()
42+
// 60 -> 20 streaming + 10 background + 2 serial, reserve 60/4 = 15.
43+
report(connectionPoolSize = 60, batchConcurrency = 13).warnings.assert.isEmpty()
44+
report(connectionPoolSize = 60, batchConcurrency = 14).warnings.assert.isNotEmpty()
4445
}
4546

4647
@Test

backend/app/src/test/kotlin/io/tolgee/configuration/AsyncExecutorConfigurationTest.kt

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class AsyncExecutorConfigurationTest {
5757

5858
asyncExecutorFactory.connectionPoolSize.assert.isEqualTo(100)
5959
asyncExecutorFactory.streamingMaxThreads.assert.isEqualTo(33)
60-
asyncExecutorFactory.streamingQueueCapacity.assert.isEqualTo(33)
60+
asyncExecutorFactory.streamingQueueCapacity.assert.isEqualTo(AsyncExecutorFactory.MIN_QUEUE_CAPACITY)
6161
asyncExecutorFactory.backgroundMaxThreads.assert.isEqualTo(16)
6262
}
6363

@@ -74,7 +74,7 @@ class AsyncExecutorConfigurationTest {
7474
streamingAsyncExecutor.threadPoolExecutor.queue
7575
.remainingCapacity()
7676
.assert
77-
.isEqualTo(33)
77+
.isEqualTo(AsyncExecutorFactory.MIN_QUEUE_CAPACITY)
7878
asyncMethodConfiguration
7979
.backgroundAsyncExecutor()
8080
.threadPoolExecutor.queue
@@ -91,13 +91,17 @@ class AsyncExecutorConfigurationTest {
9191
/** Without these, context close rejects in-flight submissions into their callers. */
9292
@Test
9393
fun `executors keep accepting work while the context closes`() {
94-
listOf(streamingAsyncExecutor, backgroundAsyncExecutor, asyncMethodConfiguration.websocketAsyncExecutor())
95-
.forEach { executor ->
96-
ReflectionTestUtils
97-
.getField(executor, "acceptTasksAfterContextClose")
98-
.assert
99-
.isEqualTo(true)
100-
}
94+
listOf(
95+
streamingAsyncExecutor,
96+
backgroundAsyncExecutor,
97+
asyncMethodConfiguration.websocketAsyncExecutor(),
98+
asyncMethodConfiguration.automationAsyncExecutor(),
99+
).forEach { executor ->
100+
ReflectionTestUtils
101+
.getField(executor, "acceptTasksAfterContextClose")
102+
.assert
103+
.isEqualTo(true)
104+
}
101105
}
102106

103107
/** Background work is worth draining; a queued stream's client is already gone. */
@@ -150,6 +154,11 @@ class AsyncExecutorConfigurationTest {
150154
val automation = asyncMethodConfiguration.automationAsyncExecutor()
151155
automation.corePoolSize.assert.isEqualTo(1)
152156
automation.maxPoolSize.assert.isEqualTo(1)
157+
automation.threadNamePrefix.assert.isEqualTo(AsyncExecutorFactory.AUTOMATION_THREAD_NAME_PREFIX)
158+
automation.threadPoolExecutor.queue
159+
.remainingCapacity()
160+
.assert
161+
.isEqualTo(Int.MAX_VALUE)
153162

154163
AutomationActivityListener::class.java.declaredMethods
155164
.filter { it.name == "listen" }

backend/app/src/test/kotlin/io/tolgee/configuration/AsyncPoolSizeDerivationTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ class AsyncPoolSizeDerivationTest {
2323
factory.backgroundMaxThreads.assert.isEqualTo(AsyncExecutorFactory.MIN_POOL_SIZE)
2424
}
2525

26+
/** A small install still deserves a burst buffer; tying the queue to 3 threads gave it a window of 6. */
27+
@Test
28+
fun `queue capacity does not shrink with the thread count`() {
29+
factory(connectionPoolSize = 10)
30+
.streamingQueueCapacity.assert
31+
.isEqualTo(AsyncExecutorFactory.MIN_QUEUE_CAPACITY)
32+
factory(connectionPoolSize = 600).streamingQueueCapacity.assert.isEqualTo(200)
33+
}
34+
2635
@Test
2736
fun `explicit configuration wins over the derivation`() {
2837
val properties = TolgeeProperties()

backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/AsyncProperties.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ class StreamingAsyncProperties {
4444
description =
4545
"How many streaming requests may wait for a free thread before Tolgee replies " +
4646
"`503 Service Unavailable`.\n\n" +
47-
"Keep this small. A queued request already counts against `spring.mvc.async.request-timeout`, " +
48-
"so a deep queue only means clients wait the full timeout and fail anyway.",
47+
"A queued request already counts against `spring.mvc.async.request-timeout`, so the queue " +
48+
"cannot make a request wait longer than that — it absorbs bursts while threads turn over, " +
49+
"and requests that still cannot be served in time are answered rather than left hanging.",
4950
defaultValue = "-1",
50-
defaultExplanation = "Same as max-threads",
51+
defaultExplanation = "50, or max-threads if that is larger",
5152
)
5253
var queueCapacity: Int = -1
5354

0 commit comments

Comments
 (0)