feat(kernel,server): rehydrate() + POST /sessions/{id}/resume
This commit is contained in:
@@ -188,6 +188,25 @@ class ServerModule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehydrates [artifactContentCache] from durable events and then resumes the session.
|
||||||
|
* Called by [POST /sessions/{id}/resume] to restart a session after a server restart
|
||||||
|
* without re-running already-completed stages.
|
||||||
|
*/
|
||||||
|
fun launchSessionResumeWithRehydrate(sessionId: SessionId, graph: com.correx.core.transitions.graph.WorkflowGraph) {
|
||||||
|
activeSessionJobs.computeIfAbsent(sessionId) {
|
||||||
|
moduleScope.launch {
|
||||||
|
runCatching {
|
||||||
|
orchestrator.rehydrate(sessionId)
|
||||||
|
orchestrator.resume(sessionId, graph, defaultOrchestrationConfig)
|
||||||
|
}.onFailure { e ->
|
||||||
|
log.error("resumeSession (rehydrate): session={} failed", sessionId.value, e)
|
||||||
|
}
|
||||||
|
activeSessionJobs.remove(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun resumeAbandonedSessions() {
|
private fun resumeAbandonedSessions() {
|
||||||
eventStore.allSessionIds().forEach { sessionId ->
|
eventStore.allSessionIds().forEach { sessionId ->
|
||||||
val orchState = orchestrationRepository.getState(sessionId)
|
val orchState = orchestrationRepository.getState(sessionId)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ fun Route.sessionRoutes(module: ServerModule) {
|
|||||||
getSessionRoute(module)
|
getSessionRoute(module)
|
||||||
cancelSessionRoute(module)
|
cancelSessionRoute(module)
|
||||||
undoSessionRoute(module)
|
undoSessionRoute(module)
|
||||||
|
resumeSessionRoute(module)
|
||||||
getEventsRoute(module)
|
getEventsRoute(module)
|
||||||
webSocket("/stream") {
|
webSocket("/stream") {
|
||||||
val id = call.parameters["id"] ?: return@webSocket
|
val id = call.parameters["id"] ?: return@webSocket
|
||||||
@@ -98,6 +99,21 @@ private fun Route.undoSessionRoute(module: ServerModule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Route.resumeSessionRoute(module: ServerModule) {
|
||||||
|
post("/resume") {
|
||||||
|
val id = call.parameters["id"]
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Missing session id")
|
||||||
|
val orchState = runCatching { module.orchestrationRepository.getState(TypeId(id)) }.getOrNull()
|
||||||
|
?: return@post call.respond(HttpStatusCode.NotFound, "Session not found")
|
||||||
|
val workflowId = orchState.workflowId.takeIf { it.isNotBlank() }
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Session has no workflowId")
|
||||||
|
val graph = module.workflowRegistry.find(workflowId)
|
||||||
|
?: return@post call.respond(HttpStatusCode.BadRequest, "Unknown workflowId: $workflowId")
|
||||||
|
module.launchSessionResumeWithRehydrate(TypeId(id), graph)
|
||||||
|
call.respond(HttpStatusCode.Accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Route.getEventsRoute(module: ServerModule) {
|
private fun Route.getEventsRoute(module: ServerModule) {
|
||||||
get("/events") {
|
get("/events") {
|
||||||
val id = call.parameters["id"]
|
val id = call.parameters["id"]
|
||||||
|
|||||||
+16
@@ -162,6 +162,22 @@ class DefaultSessionOrchestrator(
|
|||||||
fun validatedArtifactContent(sessionId: SessionId, artifactId: ArtifactId): String? =
|
fun validatedArtifactContent(sessionId: SessionId, artifactId: ArtifactId): String? =
|
||||||
artifactContentCache["${sessionId.value}:${artifactId.value}"]
|
artifactContentCache["${sessionId.value}:${artifactId.value}"]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds [artifactContentCache] from durable events after a server restart.
|
||||||
|
* Scans all events for [ArtifactValidatedEvent] and re-fetches the stored bytes
|
||||||
|
* from [artifactStore] for each one. Must be called before [resume] to ensure
|
||||||
|
* transition conditions that read artifact content work correctly.
|
||||||
|
*/
|
||||||
|
suspend fun rehydrate(sessionId: SessionId) {
|
||||||
|
repositories.eventStore.readFrom(sessionId, fromSequence = 0L).forEach { event ->
|
||||||
|
val p = event.payload as? ArtifactValidatedEvent ?: return@forEach
|
||||||
|
val key = "${sessionId.value}:${p.artifactId.value}"
|
||||||
|
artifactStore.get(p.artifactId)
|
||||||
|
?.toString(Charsets.UTF_8)
|
||||||
|
?.let { artifactContentCache[key] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun resume(
|
suspend fun resume(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
|
|||||||
+1
-1
@@ -134,7 +134,7 @@ private const val REPO_MAP_INJECT_TOP_K = 30
|
|||||||
abstract class SessionOrchestrator(
|
abstract class SessionOrchestrator(
|
||||||
repositories: OrchestratorRepositories,
|
repositories: OrchestratorRepositories,
|
||||||
engines: OrchestratorEngines,
|
engines: OrchestratorEngines,
|
||||||
private val artifactStore: ArtifactStore,
|
protected val artifactStore: ArtifactStore,
|
||||||
private val decisionJournalRepository: DefaultDecisionJournalRepository,
|
private val decisionJournalRepository: DefaultDecisionJournalRepository,
|
||||||
private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(),
|
private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import com.correx.core.approvals.ApprovalProjector
|
||||||
|
import com.correx.core.approvals.DefaultApprovalReducer
|
||||||
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||||
|
import com.correx.core.artifactstore.ArtifactStore
|
||||||
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||||
|
import com.correx.core.events.events.ArtifactValidatedEvent
|
||||||
|
import com.correx.core.events.events.EventMetadata
|
||||||
|
import com.correx.core.events.events.NewEvent
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.EventId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.inference.InferenceRepository
|
||||||
|
import com.correx.core.inference.InferenceState
|
||||||
|
import com.correx.core.journal.DecisionJournalProjector
|
||||||
|
import com.correx.core.journal.DefaultDecisionJournalReducer
|
||||||
|
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||||
|
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||||
|
import com.correx.core.risk.DefaultRiskAssessor
|
||||||
|
import com.correx.core.sessions.DefaultSessionRepository
|
||||||
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||||
|
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||||
|
import com.correx.core.utils.TypeId
|
||||||
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||||
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
|
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||||
|
import com.correx.testing.fixtures.InferenceFixtures
|
||||||
|
import com.correx.testing.fixtures.context.ContextFixtures
|
||||||
|
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
||||||
|
import com.correx.testing.kernel.MockSessionEventReplayer
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class RehydrateTest {
|
||||||
|
|
||||||
|
private val sessionId: SessionId = TypeId("session-rehydrate")
|
||||||
|
private val artifactId: ArtifactId = TypeId("artifact-001")
|
||||||
|
private val artifactContent = """{"status":"success"}"""
|
||||||
|
|
||||||
|
private val eventStore = InMemoryEventStore()
|
||||||
|
|
||||||
|
private val fakeArtifactStore: ArtifactStore = object : ArtifactStore {
|
||||||
|
override suspend fun put(bytes: ByteArray): ArtifactId = artifactId
|
||||||
|
override suspend fun get(id: ArtifactId): ByteArray? =
|
||||||
|
if (id == artifactId) artifactContent.toByteArray(Charsets.UTF_8) else null
|
||||||
|
override suspend fun flushBefore(commit: suspend () -> Unit) = commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val orchestrationRepository = OrchestrationRepository(
|
||||||
|
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val inferenceRepository = InferenceRepository(
|
||||||
|
object : EventReplayer<InferenceState> {
|
||||||
|
override fun rebuild(sessionId: SessionId) = InferenceState()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private val approvalRepository = DefaultApprovalRepository(
|
||||||
|
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val decisionJournalRepository = DefaultDecisionJournalRepository(
|
||||||
|
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val repositories = OrchestratorRepositories(
|
||||||
|
eventStore = eventStore,
|
||||||
|
inferenceRepository = inferenceRepository,
|
||||||
|
orchestrationRepository = orchestrationRepository,
|
||||||
|
sessionRepository = DefaultSessionRepository(MockSessionEventReplayer()),
|
||||||
|
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
||||||
|
approvalRepository = approvalRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val engines = OrchestratorEngines(
|
||||||
|
transitionResolver = TransitionFixtures.simpleResolver(),
|
||||||
|
contextPackBuilder = ContextFixtures.simpleBuilder(),
|
||||||
|
inferenceRouter = InferenceFixtures.fixedRouter(),
|
||||||
|
validationPipeline = ValidationPipeline(validators = emptyList()),
|
||||||
|
approvalEngine = DefaultApprovalEngine(),
|
||||||
|
riskAssessor = DefaultRiskAssessor(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val orchestrator = DefaultSessionOrchestrator(
|
||||||
|
repositories = repositories,
|
||||||
|
engines = engines,
|
||||||
|
retryCoordinator = DefaultRetryCoordinator(eventStore),
|
||||||
|
artifactStore = fakeArtifactStore,
|
||||||
|
decisionJournalRepository = decisionJournalRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun appendValidatedEvent() {
|
||||||
|
eventStore.append(
|
||||||
|
NewEvent(
|
||||||
|
metadata = EventMetadata(
|
||||||
|
eventId = EventId(UUID.randomUUID().toString()),
|
||||||
|
sessionId = sessionId,
|
||||||
|
timestamp = Clock.System.now(),
|
||||||
|
schemaVersion = 1,
|
||||||
|
causationId = null,
|
||||||
|
correlationId = null,
|
||||||
|
),
|
||||||
|
payload = ArtifactValidatedEvent(
|
||||||
|
artifactId = artifactId,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = StageId("stage-1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rehydrate populates artifactContentCache from ArtifactValidatedEvent`(): Unit = runBlocking {
|
||||||
|
appendValidatedEvent()
|
||||||
|
|
||||||
|
orchestrator.rehydrate(sessionId)
|
||||||
|
|
||||||
|
val cached = orchestrator.validatedArtifactContent(sessionId, artifactId)
|
||||||
|
assertEquals(artifactContent, cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rehydrate is a no-op when there are no ArtifactValidatedEvents`(): Unit = runBlocking {
|
||||||
|
orchestrator.rehydrate(sessionId)
|
||||||
|
|
||||||
|
val cached = orchestrator.validatedArtifactContent(sessionId, artifactId)
|
||||||
|
assertEquals(null, cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user