profile: add adaptive daily request control

This commit is contained in:
2026-08-06 16:00:04 +08:00
parent 3c69bdeff4
commit c7878030f9
6 changed files with 693 additions and 27 deletions
+3
View File
@@ -264,6 +264,9 @@ searchHistoryMaxRecords: 5000
`profileDailyGroupUpdateMaxPendingAgeDays`(默认 7 天)才自动处理,所以旧积压不会被后来的一两条新消息掩盖。 `profileDailyGroupUpdateMaxPendingAgeDays`(默认 7 天)才自动处理,所以旧积压不会被后来的一两条新消息掩盖。
每日任务不使用 20 条的人工全量启动门槛,会把合格群推进到本轮固定历史快照;内容不足门槛的参与者不会调用模型。 每日任务不使用 20 条的人工全量启动门槛,会把合格群推进到本轮固定历史快照;内容不足门槛的参与者不会调用模型。
它与人工全量共用现有的每群运行锁:仍在推进的群会跳过,已经失败并释放锁的群可以从已保存水位继续处理。 它与人工全量共用现有的每群运行锁:仍在推进的群会跳过,已经失败并释放锁的群可以从已保存水位继续处理。
每日任务内部的模型请求从并发 1 开始,健康请求按 `1 → 4 → 16 → 64 → 配置上限` 渐进放量;任一失败会暂停新请求,
已发出的请求继续完成,失败重试始终保持单请求探测。探测成功后恢复正常准入;连续 3 个不同批次各自耗尽配置的重试
次数时停止当天任务,未提交批次的水位线保持不变,下一次仍可续跑。该控制器不影响人工画像命令、实时画像或本地回填。
下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的 下一次正常群聊会自动携带触发者和最近发言者的认识。现有好感度、Bot 代号、标签和主观印象会与证据驱动的
长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。 长期画像按同一个人合并渲染,并明确给出长期画像条目数;私聊也会携带对方的可靠画像摘要和条目数。
@@ -107,13 +107,30 @@ object ProfileDailyMaintenance {
JChatGPT.logger.info("每日群画像推进开始: groups=${groupIds.size} maxPendingAgeDays=$maxPendingAgeDays") JChatGPT.logger.info("每日群画像推进开始: groups=${groupIds.size} maxPendingAgeDays=$maxPendingAgeDays")
val runToken = UserProfileAnalysisService.newRunToken() val runToken = UserProfileAnalysisService.newRunToken()
val requestController = ProfileDailyRequestController(
maxConcurrentRequests = PluginConfig.profileMaxConcurrentRequests,
maxAttempts = PluginConfig.profileRetryMax.coerceIn(0, 3) + 1,
)
val outcomes = coroutineScope { val outcomes = coroutineScope {
groupIds.map { groupId -> groupIds.map { groupId ->
async { analyzeGroup(groupId, runToken) } async { analyzeGroup(groupId, runToken, requestController) }
}.awaitAll() }.awaitAll()
} }
val requestStats = requestController.snapshot()
val reports = outcomes.mapNotNull(DailyGroupOutcome::report) val reports = outcomes.mapNotNull(DailyGroupOutcome::report)
outcomes.firstNotNullOfOrNull(DailyGroupOutcome::cause)?.let { cause ->
JChatGPT.logger.error("每日群画像推进存在失败群,本轮仅输出一次代表性异常", cause)
}
if (requestStats.countedFailures > 0) {
val message = "每日群画像请求控制: attempts=${requestStats.totalAttempts} " +
"failures=${requestStats.countedFailures} probes=${requestStats.probeAttempts} " +
"incidents=${requestStats.pauseIncidents} recovered=${requestStats.recoveredIncidents} " +
"exhausted=${requestStats.exhaustedTasks} " +
"peak=${requestStats.peakActive}/${requestStats.peakLimit} " +
"stopped=${requestStats.stopped}"
if (requestStats.stopped) JChatGPT.logger.warning(message) else JChatGPT.logger.info(message)
}
JChatGPT.logger.info( JChatGPT.logger.info(
"每日群画像推进完成: selected=${groupIds.size} " + "每日群画像推进完成: selected=${groupIds.size} " +
"success=${outcomes.count { it.status == DailyGroupStatus.SUCCESS }} " + "success=${outcomes.count { it.status == DailyGroupStatus.SUCCESS }} " +
@@ -132,12 +149,14 @@ object ProfileDailyMaintenance {
private suspend fun analyzeGroup( private suspend fun analyzeGroup(
groupId: Long, groupId: Long,
runToken: ProfileAnalysisRunToken, runToken: ProfileAnalysisRunToken,
requestController: ProfileDailyRequestController,
): DailyGroupOutcome { ): DailyGroupOutcome {
return try { return try {
val report = UserProfileAnalysisService.analyzeGroup( val report = UserProfileAnalysisService.analyzeGroupControlled(
groupId = groupId, groupId = groupId,
maxBatches = Int.MAX_VALUE, maxBatches = Int.MAX_VALUE,
runToken = runToken, runToken = runToken,
requestController = requestController,
) { progress -> ) { progress ->
JChatGPT.logger.info( JChatGPT.logger.info(
"PROFILE_DAILY_BATCH group=$groupId batch=${progress.batchIndex} " + "PROFILE_DAILY_BATCH group=$groupId batch=${progress.batchIndex} " +
@@ -155,17 +174,19 @@ object ProfileDailyMaintenance {
else -> DailyGroupStatus.SUCCESS else -> DailyGroupStatus.SUCCESS
} }
DailyGroupOutcome(status, report.takeUnless { report.alreadyRunning || report.botId == null }) DailyGroupOutcome(status, report.takeUnless { report.alreadyRunning || report.botId == null })
} catch (cause: ProfileDailyRunStoppedException) {
DailyGroupOutcome(DailyGroupStatus.STOPPED)
} catch (cause: CancellationException) { } catch (cause: CancellationException) {
throw cause throw cause
} catch (cause: Exception) { } catch (cause: Exception) {
JChatGPT.logger.error("$groupId 每日画像推进失败", cause) DailyGroupOutcome(DailyGroupStatus.FAILED, cause = cause)
DailyGroupOutcome(DailyGroupStatus.FAILED)
} }
} }
private data class DailyGroupOutcome( private data class DailyGroupOutcome(
val status: DailyGroupStatus, val status: DailyGroupStatus,
val report: GroupProfileAnalysisReport? = null, val report: GroupProfileAnalysisReport? = null,
val cause: Throwable? = null,
) )
private enum class DailyGroupStatus { private enum class DailyGroupStatus {
@@ -0,0 +1,304 @@
package top.jie65535.mirai.profile
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import top.jie65535.mirai.llm.normalizeMaxConcurrentRequests
/**
* Run-scoped admission controller for the online daily group maintenance job.
*
* The shared ModelService semaphore remains the hard upper bound. This gate
* only controls this one bulk run: it ramps healthy admission from one request,
* pauses fresh work after a failure, and keeps recovery retries single-flight.
*/
internal class ProfileDailyRequestController(
maxConcurrentRequests: Int,
private val maxAttempts: Int,
private val growthFactor: Int = 4,
private val maxConsecutiveExhaustedTasks: Int = 3,
) {
init {
require(maxAttempts > 0) { "maxAttempts must be positive" }
require(growthFactor >= 2) { "growthFactor must be at least 2" }
require(maxConsecutiveExhaustedTasks > 0) {
"maxConsecutiveExhaustedTasks must be positive"
}
}
private val maxConcurrency = normalizeMaxConcurrentRequests(maxConcurrentRequests)
private val mutex = Mutex()
private val waiters = ArrayDeque<Waiter>()
private val active = LinkedHashMap<Long, Permit>()
private val heldFailures = LinkedHashSet<ProfileDailyRequestKey>()
private val exhaustedTasks = LinkedHashSet<ProfileDailyRequestKey>()
private var nextPermitId = 1L
private var admissionLimit = 1
private var successesAtLimit = 0
private var paused = false
private var incidentOpen = false
private var stopped = false
private var consecutiveExhaustedTasks = 0
private var freshProbeSuccesses = 0
private var totalAttempts = 0
private var countedFailures = 0
private var probeAttempts = 0
private var pauseIncidents = 0
private var recoveredIncidents = 0
private var peakActive = 0
private var peakLimit = 1
private var maxParallelProbes = 0
/**
* Admits and records one model attempt. A rejected/safety response can be
* excluded from the global failure circuit without affecting the gate.
*/
suspend fun <T> execute(
key: ProfileDailyRequestKey,
attempt: Int,
countFailure: (Throwable) -> Boolean = { true },
block: suspend () -> T,
): T {
require(attempt in 1..maxAttempts) { "attempt must be within 1..$maxAttempts" }
val permit = awaitPermit(key, attempt)
return try {
val result = block()
onSuccess(permit)
result
} catch (cause: CancellationException) {
onCancellation(permit)
throw cause
} catch (cause: Throwable) {
if (countFailure(cause)) onFailure(permit, attempt)
else release(permit)
throw cause
}
}
suspend fun snapshot(): ProfileDailyRequestControllerStats = mutex.withLock {
ProfileDailyRequestControllerStats(
totalAttempts = totalAttempts,
countedFailures = countedFailures,
probeAttempts = probeAttempts,
pauseIncidents = pauseIncidents,
recoveredIncidents = recoveredIncidents,
exhaustedTasks = exhaustedTasks.size,
stopped = stopped,
peakActive = peakActive,
peakLimit = peakLimit,
finalLimit = admissionLimit,
maxParallelProbes = maxParallelProbes,
)
}
private suspend fun awaitPermit(
key: ProfileDailyRequestKey,
attempt: Int,
): Permit {
val waiter = Waiter(key, attempt)
mutex.withLock {
if (stopped) {
waiter.deferred.completeExceptionally(ProfileDailyRunStoppedException)
} else {
waiters.addLast(waiter)
pumpLocked()
}
}
return try {
waiter.deferred.await()
} catch (cause: CancellationException) {
mutex.withLock {
waiters.remove(waiter)
waiter.permit?.let { permit -> active.remove(permit.id) }
waiter.deferred.cancel(cause)
pumpLocked()
}
throw cause
}
}
private suspend fun onSuccess(permit: Permit) {
mutex.withLock {
active.remove(permit.id)
if (permit.kind == PermitKind.RECOVERY) {
heldFailures.remove(permit.key)
if (paused) resumeAfterRecoverySuccessLocked()
} else if (permit.kind == PermitKind.FRESH_PROBE) {
freshProbeSuccesses++
}
consecutiveExhaustedTasks = 0
successesAtLimit++
if (admissionLimit < maxConcurrency && successesAtLimit >= admissionLimit) {
admissionLimit = minOf(maxConcurrency, admissionLimit * growthFactor)
successesAtLimit = 0
peakLimit = maxOf(peakLimit, admissionLimit)
}
if (paused && heldFailures.isEmpty() && freshProbeSuccesses >= FRESH_PROBE_SUCCESS_TARGET) {
resumeAfterFreshProbesLocked()
}
pumpLocked()
}
}
private suspend fun onFailure(permit: Permit, attempt: Int) {
mutex.withLock {
active.remove(permit.id)
countedFailures++
if (stopped) return@withLock
if (!incidentOpen) {
incidentOpen = true
paused = true
pauseIncidents++
}
heldFailures.add(permit.key)
if (attempt >= maxAttempts) {
heldFailures.remove(permit.key)
if (exhaustedTasks.add(permit.key)) consecutiveExhaustedTasks++
if (consecutiveExhaustedTasks >= maxConsecutiveExhaustedTasks) {
stopped = true
failWaitingLocked()
}
}
pumpLocked()
}
}
private suspend fun onCancellation(permit: Permit) {
mutex.withLock {
active.remove(permit.id)
pumpLocked()
}
}
private suspend fun release(permit: Permit) {
mutex.withLock {
active.remove(permit.id)
pumpLocked()
}
}
private fun resumeAfterRecoverySuccessLocked() {
paused = false
if (incidentOpen) {
recoveredIncidents++
incidentOpen = false
}
freshProbeSuccesses = 0
}
private fun resumeAfterFreshProbesLocked() {
paused = false
if (incidentOpen) {
recoveredIncidents++
incidentOpen = false
}
freshProbeSuccesses = 0
}
private fun pumpLocked() {
removeInactiveWaitersLocked()
if (stopped) return
while (active.size < admissionLimit) {
val waiter = when {
!paused -> findNormalOrRecoveryWaiterLocked()
heldFailures.isNotEmpty() -> {
if (active.values.any { it.kind == PermitKind.RECOVERY }) null
else waiters.firstOrNull { it.attempt > 1 && heldFailures.contains(it.key) }
}
freshProbeSuccesses < FRESH_PROBE_SUCCESS_TARGET -> {
waiters.firstOrNull { it.attempt == 1 }
}
else -> null
} ?: break
waiters.remove(waiter)
val kind = when {
waiter.attempt > 1 && heldFailures.contains(waiter.key) -> PermitKind.RECOVERY
paused -> PermitKind.FRESH_PROBE
else -> PermitKind.NORMAL
}
val permit = Permit(nextPermitId++, waiter.key, kind)
waiter.permit = permit
active[permit.id] = permit
totalAttempts++
if (kind != PermitKind.NORMAL) probeAttempts++
peakActive = maxOf(peakActive, active.size)
maxParallelProbes = maxOf(
maxParallelProbes,
active.values.count { it.kind != PermitKind.NORMAL },
)
waiter.deferred.complete(permit)
if (kind == PermitKind.RECOVERY) break
if (paused) break
}
}
private fun findNormalOrRecoveryWaiterLocked(): Waiter? {
if (heldFailures.isNotEmpty() && active.values.none { it.kind == PermitKind.RECOVERY }) {
return waiters.firstOrNull { it.attempt > 1 && heldFailures.contains(it.key) }
}
return waiters.firstOrNull { it.attempt == 1 }
}
private fun removeInactiveWaitersLocked() {
waiters.removeAll { !it.deferred.isActive }
}
private fun failWaitingLocked() {
waiters.forEach { it.deferred.completeExceptionally(ProfileDailyRunStoppedException) }
waiters.clear()
}
private data class Waiter(
val key: ProfileDailyRequestKey,
val attempt: Int,
val deferred: CompletableDeferred<Permit> = CompletableDeferred(),
var permit: Permit? = null,
)
private data class Permit(
val id: Long,
val key: ProfileDailyRequestKey,
val kind: PermitKind,
)
private enum class PermitKind {
NORMAL,
RECOVERY,
FRESH_PROBE,
}
companion object {
private const val FRESH_PROBE_SUCCESS_TARGET = 2
}
}
internal data class ProfileDailyRequestKey(
val groupId: Long,
val startTime: Int,
val endTime: Int,
)
internal data class ProfileDailyRequestControllerStats(
val totalAttempts: Int,
val countedFailures: Int,
val probeAttempts: Int,
val pauseIncidents: Int,
val recoveredIncidents: Int,
val exhaustedTasks: Int,
val stopped: Boolean,
val peakActive: Int,
val peakLimit: Int,
val finalLimit: Int,
val maxParallelProbes: Int,
)
internal object ProfileDailyRunStoppedException : RuntimeException(
"每日群画像推进已因连续模型请求失败而停止",
)
@@ -170,6 +170,34 @@ object UserProfileAnalysisService {
maxBatches: Int, maxBatches: Int,
runToken: ProfileAnalysisRunToken = newRunToken(), runToken: ProfileAnalysisRunToken = newRunToken(),
onProgress: suspend (GroupProfileAnalysisProgress) -> Unit = {}, onProgress: suspend (GroupProfileAnalysisProgress) -> Unit = {},
): GroupProfileAnalysisReport = analyzeGroupInternal(
groupId = groupId,
maxBatches = maxBatches,
runToken = runToken,
requestController = null,
onProgress = onProgress,
)
internal suspend fun analyzeGroupControlled(
groupId: Long,
maxBatches: Int,
runToken: ProfileAnalysisRunToken,
requestController: ProfileDailyRequestController,
onProgress: suspend (GroupProfileAnalysisProgress) -> Unit = {},
): GroupProfileAnalysisReport = analyzeGroupInternal(
groupId = groupId,
maxBatches = maxBatches,
runToken = runToken,
requestController = requestController,
onProgress = onProgress,
)
private suspend fun analyzeGroupInternal(
groupId: Long,
maxBatches: Int,
runToken: ProfileAnalysisRunToken,
requestController: ProfileDailyRequestController?,
onProgress: suspend (GroupProfileAnalysisProgress) -> Unit,
): GroupProfileAnalysisReport { ): GroupProfileAnalysisReport {
require(groupId > 0) { "groupId 必须是正数" } require(groupId > 0) { "groupId 必须是正数" }
require(maxBatches > 0) { "maxBatches 必须是正数" } require(maxBatches > 0) { "maxBatches 必须是正数" }
@@ -194,7 +222,7 @@ object UserProfileAnalysisService {
} }
try { try {
return analyzeGroupExclusive(groupId, maxBatches, runToken, onProgress) return analyzeGroupExclusive(groupId, maxBatches, runToken, requestController, onProgress)
} finally { } finally {
runningGroups.remove(groupId) runningGroups.remove(groupId)
} }
@@ -306,6 +334,7 @@ object UserProfileAnalysisService {
groupId: Long, groupId: Long,
maxBatches: Int, maxBatches: Int,
runToken: ProfileAnalysisRunToken, runToken: ProfileAnalysisRunToken,
requestController: ProfileDailyRequestController?,
onProgress: suspend (GroupProfileAnalysisProgress) -> Unit, onProgress: suspend (GroupProfileAnalysisProgress) -> Unit,
): GroupProfileAnalysisReport { ): GroupProfileAnalysisReport {
val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveHistoryFile()) } val reader = withContext(Dispatchers.IO) { ProfileHistoryReader(resolveHistoryFile()) }
@@ -367,6 +396,7 @@ object UserProfileAnalysisService {
summaryMaxLength = PluginConfig.profileSummaryMaxLength, summaryMaxLength = PluginConfig.profileSummaryMaxLength,
onRetryFailure = { message, cause -> JChatGPT.logger.warning(message, cause) }, onRetryFailure = { message, cause -> JChatGPT.logger.warning(message, cause) },
onCommittedOperations = ProfileOperationLogger::log, onCommittedOperations = ProfileOperationLogger::log,
requestController = requestController,
) )
} catch (cause: ModelSafetyRejectionException) { } catch (cause: ModelSafetyRejectionException) {
JChatGPT.logger.warning( JChatGPT.logger.warning(
@@ -461,6 +491,7 @@ object UserProfileAnalysisService {
summaryMaxLength: Int, summaryMaxLength: Int,
onRetryFailure: (String, Throwable) -> Unit = { _, _ -> }, onRetryFailure: (String, Throwable) -> Unit = { _, _ -> },
onCommittedOperations: (String, Collection<ProfileReduction>) -> Unit = { _, _ -> }, onCommittedOperations: (String, Collection<ProfileReduction>) -> Unit = { _, _ -> },
requestController: ProfileDailyRequestController? = null,
): ConversationProfileAnalysisReport? { ): ConversationProfileAnalysisReport? {
check(UserProfileStore.isAvailable) { "用户画像数据库不可用" } check(UserProfileStore.isAvailable) { "用户画像数据库不可用" }
val eligibleUserIds = batch.authoredTextCharsByUser val eligibleUserIds = batch.authoredTextCharsByUser
@@ -486,6 +517,7 @@ object UserProfileAnalysisService {
retryMax = retryMax, retryMax = retryMax,
summaryMaxLength = summaryMaxLength, summaryMaxLength = summaryMaxLength,
onRetryFailure = onRetryFailure, onRetryFailure = onRetryFailure,
requestController = requestController,
) )
val totalUsage = result.usage val totalUsage = result.usage
val commitOutcome = userLocks.withUserLocks(eligibleUserIds) { val commitOutcome = userLocks.withUserLocks(eligibleUserIds) {
@@ -569,36 +601,57 @@ object UserProfileAnalysisService {
retryMax: Int, retryMax: Int,
summaryMaxLength: Int, summaryMaxLength: Int,
onRetryFailure: (String, Throwable) -> Unit, onRetryFailure: (String, Throwable) -> Unit,
requestController: ProfileDailyRequestController?,
): Pair<ConversationProfileModelResult, List<ProfileReduction>> { ): Pair<ConversationProfileModelResult, List<ProfileReduction>> {
val attempts = retryMax.coerceIn(0, 3) + 1 val attempts = retryMax.coerceIn(0, 3) + 1
val retryBackoff = RetryBackoff.fromConfig() val retryBackoff = RetryBackoff.fromConfig()
val requestKey = ProfileDailyRequestKey(
groupId = batch.groupId,
startTime = batch.startTime,
endTime = batch.endTime,
)
var lastFailure: Throwable? = null var lastFailure: Throwable? = null
repeat(attempts) { attempt -> repeat(attempts) { attempt ->
try { try {
val result = model.analyzeConversation( val analyzeAttempt: suspend () -> Pair<ConversationProfileModelResult, List<ProfileReduction>> = {
profiles, val result = model.analyzeConversation(
batch, profiles,
eligibleUserIds, batch,
supportStatsByUserId, eligibleUserIds,
) supportStatsByUserId,
val reductions = ConversationProfileReducer.reduce( )
profiles = profiles, val reductions = ConversationProfileReducer.reduce(
batch = batch, profiles = profiles,
eligibleUserIds = eligibleUserIds, batch = batch,
response = result.response, eligibleUserIds = eligibleUserIds,
model = model.modelName, response = result.response,
promptVersion = ProfilePromptStore.PROMPT_VERSION, model = model.modelName,
summaryMaxLength = summaryMaxLength.coerceAtLeast(100), promptVersion = ProfilePromptStore.PROMPT_VERSION,
) summaryMaxLength = summaryMaxLength.coerceAtLeast(100),
return result to reductions )
result to reductions
}
return if (requestController == null) {
analyzeAttempt()
} else {
requestController.execute(
key = requestKey,
attempt = attempt + 1,
countFailure = { cause -> cause !is ModelRequestRejectedException },
block = analyzeAttempt,
)
}
} catch (cause: Exception) { } catch (cause: Exception) {
if (cause is ProfileDailyRunStoppedException) throw cause
if (cause is CancellationException) throw cause if (cause is CancellationException) throw cause
if (cause is ModelRequestRejectedException) { if (cause is ModelRequestRejectedException) {
onRetryFailure( if (requestController == null) {
"${batch.groupId} 会话画像 [${batch.startTime}, ${batch.endTime}) " + onRetryFailure(
"被模型拒绝,已停止重试", "${batch.groupId} 会话画像 [${batch.startTime}, ${batch.endTime}) " +
cause, "被模型拒绝,已停止重试",
) cause,
)
}
throw cause throw cause
} }
lastFailure = cause lastFailure = cause
@@ -609,7 +662,7 @@ object UserProfileAnalysisService {
"${attempt + 1}/$attempts 次分析失败", "${attempt + 1}/$attempts 次分析失败",
cause = cause, cause = cause,
retryBackoff = retryBackoff, retryBackoff = retryBackoff,
logFailure = onRetryFailure, logFailure = if (requestController == null) onRetryFailure else { _, _ -> },
) )
} }
} }
@@ -0,0 +1,258 @@
package top.jie65535.mirai.profile
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ProfileDailyRequestControllerTest {
@Test
fun growsHealthyAdmissionByFourWithoutExceedingTheConfiguredCap() = runBlocking {
val controller = controller(maxConcurrency = 128)
succeed(controller, key(1))
assertEquals(4, controller.snapshot().finalLimit)
repeat(4) { index -> succeed(controller, key(index + 2)) }
assertEquals(16, controller.snapshot().finalLimit)
repeat(16) { index -> succeed(controller, key(index + 6)) }
assertEquals(64, controller.snapshot().finalLimit)
repeat(64) { index -> succeed(controller, key(index + 22)) }
val stats = controller.snapshot()
assertEquals(128, stats.finalLimit)
assertEquals(128, stats.peakLimit)
assertFalse(stats.stopped)
}
@Test
fun pausesFreshAdmissionUntilTheFailedTaskRecovers() = runBlocking {
val controller = controller(maxConcurrency = 4)
succeed(controller, key(1))
val failedKey = key(2)
fail(controller, failedKey, attempt = 1)
val freshStarted = CompletableDeferred<Unit>()
coroutineScope {
val fresh = async {
controller.execute(key(3), attempt = 1) {
freshStarted.complete(Unit)
}
}
assertNull(withTimeoutOrNull(50) { freshStarted.await() })
succeed(controller, failedKey, attempt = 2)
withTimeout(1_000) { freshStarted.await() }
fresh.await()
}
val stats = controller.snapshot()
assertEquals(1, stats.countedFailures)
assertEquals(1, stats.recoveredIncidents)
assertEquals(1, stats.maxParallelProbes)
assertFalse(stats.stopped)
}
@Test
fun admitsNewWorkWhenOneFastRequestFinishesBeforeSlowRequests() = runBlocking {
val controller = controller(maxConcurrency = 4)
succeed(controller, key(1))
val allEntered = CompletableDeferred<Unit>()
val releaseFast = CompletableDeferred<Unit>()
val releaseSlow = CompletableDeferred<Unit>()
val entered = AtomicInteger()
coroutineScope {
val activeRequests = (2..5).map { index ->
async {
controller.execute(key(index), attempt = 1) {
if (entered.incrementAndGet() == 4) allEntered.complete(Unit)
if (index == 2) releaseFast.await() else releaseSlow.await()
}
}
}
withTimeout(1_000) { allEntered.await() }
val newWorkStarted = CompletableDeferred<Unit>()
val queued = async {
controller.execute(key(6), attempt = 1) {
newWorkStarted.complete(Unit)
}
}
assertNull(withTimeoutOrNull(50) { newWorkStarted.await() })
releaseFast.complete(Unit)
withTimeout(1_000) { newWorkStarted.await() }
releaseSlow.complete(Unit)
activeRequests.forEach { it.await() }
queued.await()
}
assertFalse(controller.snapshot().stopped)
}
@Test
fun keepsMultipleFailedTaskRetriesSingleFlight() = runBlocking {
val controller = controller(maxConcurrency = 4)
succeed(controller, key(1))
val failedKeys = listOf(key(2), key(3))
val allInitialAttemptsEntered = CompletableDeferred<Unit>()
val releaseInitialAttempts = CompletableDeferred<Unit>()
val initialEntered = AtomicInteger()
coroutineScope {
val initialAttempts = (failedKeys + key(4) + key(5)).mapIndexed { index, requestKey ->
async {
runCatching {
controller.execute(requestKey, attempt = 1) {
if (initialEntered.incrementAndGet() == 4) {
allInitialAttemptsEntered.complete(Unit)
}
releaseInitialAttempts.await()
if (index < 2) throw IOException("transient")
}
}
}
}
withTimeout(1_000) { allInitialAttemptsEntered.await() }
releaseInitialAttempts.complete(Unit)
initialAttempts.forEach { it.await() }
val activeRetries = AtomicInteger()
val peakRetries = AtomicInteger()
val firstRetryEntered = CompletableDeferred<Unit>()
val releaseRetries = CompletableDeferred<Unit>()
val retries = failedKeys.map { requestKey ->
async {
controller.execute(requestKey, attempt = 2) {
val current = activeRetries.incrementAndGet()
peakRetries.updateAndGet { previous -> maxOf(previous, current) }
firstRetryEntered.complete(Unit)
releaseRetries.await()
activeRetries.decrementAndGet()
}
}
}
withTimeout(1_000) { firstRetryEntered.await() }
delay(50)
assertEquals(1, peakRetries.get())
releaseRetries.complete(Unit)
retries.forEach { it.await() }
}
val stats = controller.snapshot()
assertEquals(1, stats.maxParallelProbes)
assertEquals(0, stats.exhaustedTasks)
assertFalse(stats.stopped)
}
@Test
fun stopsAfterThreeDifferentTasksExhaustTheirAttempts() = runBlocking {
val controller = controller(maxConcurrency = 128)
repeat(3) { taskIndex ->
repeat(3) { attemptIndex ->
fail(controller, key(taskIndex + 1), attemptIndex + 1)
}
}
val stats = controller.snapshot()
assertTrue(stats.stopped)
assertEquals(9, stats.totalAttempts)
assertEquals(9, stats.countedFailures)
assertEquals(8, stats.probeAttempts)
assertEquals(3, stats.exhaustedTasks)
assertFailsWith<ProfileDailyRunStoppedException> {
controller.execute(key(4), attempt = 1) { }
}
Unit
}
@Test
fun onePersistentBadTaskDoesNotStopHealthyWork() = runBlocking {
val controller = controller(maxConcurrency = 128)
repeat(3) { attemptIndex -> fail(controller, key(1), attemptIndex + 1) }
assertFalse(controller.snapshot().stopped)
succeed(controller, key(2))
succeed(controller, key(3))
succeed(controller, key(4))
val stats = controller.snapshot()
assertFalse(stats.stopped)
assertEquals(1, stats.exhaustedTasks)
assertEquals(1, stats.recoveredIncidents)
}
@Test
fun cancellationWhileWaitingDoesNotLeaveAQueuedRequest() = runBlocking {
val controller = controller(maxConcurrency = 1)
val firstEntered = CompletableDeferred<Unit>()
val releaseFirst = CompletableDeferred<Unit>()
coroutineScope {
val first = async {
controller.execute(key(1), attempt = 1) {
firstEntered.complete(Unit)
releaseFirst.await()
}
}
firstEntered.await()
val cancelled = async {
controller.execute(key(2), attempt = 1) {
error("cancelled waiter must not start")
}
}
delay(50)
cancelled.cancelAndJoin()
releaseFirst.complete(Unit)
first.await()
}
succeed(controller, key(3))
assertEquals(2, controller.snapshot().totalAttempts)
}
private fun controller(maxConcurrency: Int) = ProfileDailyRequestController(
maxConcurrentRequests = maxConcurrency,
maxAttempts = 3,
growthFactor = 4,
maxConsecutiveExhaustedTasks = 3,
)
private suspend fun succeed(
controller: ProfileDailyRequestController,
key: ProfileDailyRequestKey,
attempt: Int = 1,
) {
controller.execute(key, attempt) { }
}
private suspend fun fail(
controller: ProfileDailyRequestController,
key: ProfileDailyRequestKey,
attempt: Int,
) {
assertFailsWith<IOException> {
controller.execute(key, attempt) { throw IOException("unavailable") }
}
}
private fun key(index: Int) = ProfileDailyRequestKey(
groupId = index.toLong(),
startTime = index * 100,
endTime = index * 100 + 50,
)
}
@@ -142,6 +142,31 @@ class UserProfileAnalysisServiceTest {
assertFalse(UserProfileStore.isConversationProcessed(INPUT_HASH)) assertFalse(UserProfileStore.isConversationProcessed(INPUT_HASH))
} }
@Test
fun dailyControllerDoesNotCountSafetyRejectionAsAnOutage() = withProfileStore {
val controller = ProfileDailyRequestController(
maxConcurrentRequests = 128,
maxAttempts = 3,
)
val model = FakeConversationProfileModel {
throw ModelSafetyRejectionException(
errorType = "invalid_request_error",
errorCode = "cyber_policy",
message = "blocked by policy",
)
}
assertFailsWith<ModelSafetyRejectionException> {
analyze(batch(), model, retryMax = 2, requestController = controller)
}
val stats = controller.snapshot()
assertEquals(1, stats.totalAttempts)
assertEquals(0, stats.countedFailures)
assertEquals(0, stats.pauseIncidents)
assertFalse(stats.stopped)
}
@Test @Test
fun skipsConversationAlreadyProcessedByAnEarlierCall() = withProfileStore { fun skipsConversationAlreadyProcessedByAnEarlierCall() = withProfileStore {
val model = FakeConversationProfileModel { successfulResult() } val model = FakeConversationProfileModel { successfulResult() }
@@ -250,12 +275,14 @@ class UserProfileAnalysisServiceTest {
model: ConversationProfileModel, model: ConversationProfileModel,
minAuthoredTextChars: Int = 1, minAuthoredTextChars: Int = 1,
retryMax: Int = 0, retryMax: Int = 0,
requestController: ProfileDailyRequestController? = null,
): ConversationProfileAnalysisReport? = UserProfileAnalysisService.analyzeConversationBatch( ): ConversationProfileAnalysisReport? = UserProfileAnalysisService.analyzeConversationBatch(
batch = batch, batch = batch,
minAuthoredTextChars = minAuthoredTextChars, minAuthoredTextChars = minAuthoredTextChars,
model = model, model = model,
retryMax = retryMax, retryMax = retryMax,
summaryMaxLength = 500, summaryMaxLength = 500,
requestController = requestController,
) )
private fun successfulResult() = result( private fun successfulResult() = result(