ADR-0026: Dual-Plane Concurrency & Async Queue Backpressure¶
| Field | Value |
|---|---|
| Status | Accepted (Implemented) |
| Date | 2026-08-25 |
| Authors | Spector Maintainers & Architecture Working Group |
| Deciders | Spector Technical Steering Committee (TSC) |
| Supersedes | ADR-0026 Draft (Library-Owned ThreadManager) |
| Superseded By | None |
| Last Verified | 2026-09-16 (Verified against main) |
1. Context¶
Spector decouples background work from the RememberPathway / RecallPathway hot path through asynchronous queues, fire-and-forget dispatch, supervised daemons, and a Quartz scheduler:
| Component | Module | Role today |
|---|---|---|
ConcurrentTasks | spector-commons | Static structured-concurrency + process-global FIRE_FORGET_EXECUTOR |
SpectorTaskQueue / TaskQueueManager | spector-commons | Priority queue; starts named virtual workers via ThreadFactory |
DaemonSupervisor | spector-commons | Long-running loops via Thread.ofVirtual().start() |
VirtualThreadPool | spector-commons | Quartz org.quartz.spi.ThreadPool → ConcurrentTasks.virtualExecutor() |
QuartzMemoryScheduler | spector-memory | Sleep consolidation, REM dream, checkpoint, DMN, decay, graph enrichment |
AsyncEntityExtractionQueue | spector-memory | LLM extract then PostIngestSync graph writes on the same VT worker |
EagerConsolidator | spector-memory | Segment walk + CADP + graph mutation on a VT worker |
| Synapse extras | spector-synapse | MemoryService, MemoryAccessObject, ConversationReflector each create their own newVirtualThreadPerTaskExecutor() |
Hosts that embed spector-memory:
- Spring Boot 4.1 (
spector-synapse) —TaskExecutorbeans,@Async,SchedulerFactoryBean, graceful shutdown - Standalone CLI / bench (
spector-bench,spector-cli) - In-process MCP kernel (
npx @spectrayan/spector mcp) - Tests
JDK 25 baseline this ADR assumes¶
- Virtual threads (final) for blocking I/O only
StructuredTaskScope(JEP 505) for short-lived fan-out; cancellation and joining owned by the JVMScopedValue/MemoryScopefor session + namespace propagation across virtual threads and structured subtasks- Panama FFM
Arena/MemorySegmentfor off-heap slabs;Arena.close()is a hard unmap StampedLockSWMR on graph kernels (EntityDirectory,HyperEntityGraphMemory,TemporalKnowledgeGraph,RuntimeBundle) — not reentrant, neversynchronized(pins carriers)- No
Executors.newVirtualThreadPerTaskExecutor()as a library singleton
2. Problem Statement¶
- Virtual threads on CPU / mmap work. NER scoring, CSR indexing, slab pointer math,
MemorySegment.force(), Hebbian updates are cache-local and carrier-bound. Hundreds of virtual workers invalidate L1/L2 and park carriers onStampedLock.writeLock(). - SWMR lock stampede. Graph structures are single-writer. Parallel VT mutators do not increase throughput; they increase park/unpark rate.
- Anonymous, unsinkable work. Global fire-and-forget threads named
[virtual-141]. Quartz jobs ride the same executor. No drain set forArena.close(). - Arena close races. In-flight workers touch unmapped segments →
IllegalStateException: Arena is already closed. - Host fight. Spring Boot 4 already owns thread lifecycle. Synapse also allocates extra VT executors. The library's static executor is process-global:
ConcurrentTasks.shutdown()on oneSpectorMemorypoisons every other instance. - Quartz plane blindness.
VirtualThreadPool.runInThread()submits every job — includingCheckpointJob(MemorySegment.force(), WAL truncate) andSleepConsolidationJob(reflect()) — onto virtual threads.blockForAvailableThreads()returns1000andgetPoolSize()returnsInteger.MAX_VALUE, so Quartz believes it has infinite capacity. - Mixed-plane tasks.
AsyncEntityExtractionQueue.processTaskdoes 15–25s LLM I/O then exclusive graph writes on the same worker. One thread type cannot be correct for both halves. - Soft, racy queue bounds.
PriorityBlockingQueueis unbounded. Capacity isqueue.size() >= config.capacity()thenoffer.DROP_OLDESTcallspoll(), which drops the priority head, not the oldest task.CALLER_RUNSexecutes mmap work on the ingestion thread.
3. Decision Drivers¶
- Zero Library-Started Raw Threads: The memory library must never call
Thread.start(),Thread.ofVirtual(), or instantiate unbounded executors. - Strict Thread Plane Classification: Every unit of asynchronous work must be classified onto a dedicated execution plane (
VIRTUAL,PLATFORM_SHARED,PLATFORM_WRITER). - Host-Controlled Provisioning: Host applications (Spring Boot, standalone CLI, MCP, tests) own thread provisioning via
SpectorExecutorProvider. - Zero-Race Arena Lifecycle Closure: Off-heap Panama FFM
Arena.close()must follow a deterministic, happens-before drain budget. - Hard Queue Backpressure: Eliminates unbounded queues, racy size checks, and priority head dropping under saturation.
4. Considered Options¶
Option 1 — Status quo¶
Library-owned unmonitored virtual threads. Rejected: cache thrash, lock stampede, arena races, host fight.
Option 2 — Library-owned SpectorThreadManager¶
Centralized but still owns threads inside a library that Spring also manages. Couples provisioning, queuing, and monitoring. Rejected.
Option 3 — Executor injection + thread classification SPI (selected, revised)¶
Libraries declare the plane of work. Hosts provision executors. Nobody starts raw threads for dispatched work.
Revisions versus the 2026-08-25 sketch:
| 2026-08-25 sketch | This revision |
|---|---|
Two planes (PLATFORM, VIRTUAL) | Three planes (VIRTUAL, PLATFORM_SHARED, PLATFORM_WRITER) |
Queues accept a ThreadFactory and thread.start() | Queues submit to an Executor (Model B). No library-started workers |
namedVirtualThreadFactory only | Plane-typed executor(ThreadPlane, name) — one SPI method |
drainBeforeArenaClose as the close protocol | Ordered happens-before close budget; drain is one step, not the protocol |
| Quartz out of scope | Quartz ThreadPool is a first-class adapter on the same SPI |
| Monitoring pushed entirely to the host | Host owns pool metrics; library keeps task observability (QueueMetrics, DaemonStatus) |
| "~15 memory call sites" | Full inventory across commons, events, memory, Quartz, Synapse |
DefaultExecutorProvider mentioned | Specified as the embed/MCP/test implementation |
5. Decision Outcome¶
Adopt revised Option 3.
Adopt revised Option 3.
Non-negotiable principles¶
- The memory library never calls
Thread.start(),Thread.ofVirtual(), orExecutors.newVirtualThreadPerTaskExecutor()for long-lived or fire-and-forget work. Structured concurrency scopes are the only exception (JVM-owned, method-scoped). - Every unit of work is classified onto exactly one
ThreadPlanebefore submission. Mixed-plane methods are split at the call site. - Hosts implement
SpectorExecutorProvider. Spring, standalone, MCP, and tests each ship one. Discovery: explicit install, thenServiceLoader, thenDefaultExecutorProvider. -
Provisioning, queuing, scheduling, and monitoring do not share types.
- Provisioning →
SpectorExecutorProvider - Queuing →
SpectorTaskQueue/TaskQueueManager - Scheduling →
QuartzMemoryScheduler+SpectorQuartzThreadPool - Task observability →
QueueMetrics/DaemonStatus(library) - Pool observability → Micrometer /
ThreadMonitor(host)
- Provisioning →
-
One close budget. One happens-before list. Arenas close last.
- Backpressure lives on one layer per path, not on both the queue and the executor.
flowchart TD
subgraph Classify["Work classification (library)"]
W[Unit of work] --> P{ThreadPlane}
P -->|VIRTUAL| VExec
P -->|PLATFORM_SHARED| SExec
P -->|PLATFORM_WRITER| XExec
end
subgraph SPI["SpectorExecutorProvider (host owns threads)"]
VExec[executor VIRTUAL]
SExec[executor PLATFORM_SHARED]
XExec[executor PLATFORM_WRITER]
end
subgraph Consumers["Library consumers — submit only"]
CT[ConcurrentTasks.fireAndForget]
STQ[SpectorTaskQueue]
DS[DaemonSupervisor]
QZ[SpectorQuartzThreadPool]
end
CT --> VExec
STQ --> VExec
STQ --> SExec
STQ --> XExec
DS --> SExec
DS --> XExec
QZ --> VExec
QZ --> SExec
QZ --> XExec
subgraph Hosts["Host implementations"]
Spring[SpringExecutorProvider]
Stand[StandaloneExecutorProvider]
Def[DefaultExecutorProvider]
Test[TestExecutorProvider]
end
Spring -.-> SPI
Stand -.-> SPI
Def -.-> SPI
Test -.-> SPI Detailed design¶
1. Three planes¶
Two planes are too coarse. Graph kernels are SWMR. Extra platform threads on a writeLock() recreate the park storm the ADR exists to stop.
/**
* Classification of a unit of work. Chosen by the library at the call site.
* The host maps each plane onto an executor appropriate to its runtime.
*/
public enum ThreadPlane {
/**
* Blocking I/O or event dispatch. Host should provide virtual-thread
* dispatch (unbounded or lightly bounded).
* Examples: LLM HTTP, webhooks, SSE, recall listeners, connector sync.
*/
VIRTUAL,
/**
* CPU-bound work that does not take an exclusive slab/graph write lock.
* Host should provide a bounded platform pool sized to available
* processors (typically min(4, N_CPU) or N_CPU - 1).
* Examples: TF-IDF, quantization, vector encode, BM25 partition scan
* internals that already have their own structured-concurrency fan-out.
*/
PLATFORM_SHARED,
/**
* Exclusive mutation of a StampedLock-guarded off-heap structure, or
* MemorySegment.force() / remap / WAL truncate.
* Host should provide a single-thread (or 1-per-namespace) platform
* executor. Parallelism greater than 1 on the same structure is a
* defect, not a tuning knob.
* Examples: EntityDirectory linkage, HyperEntity mutations, CSR
* compaction, EagerConsolidator CADP writes, checkpoint force().
*/
PLATFORM_WRITER
}
Classification table (normative)¶
| Work | Plane | Notes |
|---|---|---|
| Recall / EventBus listener fan-out | VIRTUAL | Must not block the recall hot path |
LLM entity extract (EntityExtractor.extract) | VIRTUAL | 15–25s blocking HTTP |
| Webhook / SSE / connector I/O | VIRTUAL | |
| Quartz trigger thread | host scheduler thread | Not our work; do not replace Quartz internals |
RemDreamJob LLM half | VIRTUAL | Persist results via writer queue |
| TF-IDF / encode / quantize | PLATFORM_SHARED | |
EagerConsolidator segment walk + CADP write | PLATFORM_WRITER | Serial per namespace |
PostIngestSync graph writes | PLATFORM_WRITER | Split from extract |
Circadian reflect() / SleepConsolidationJob | PLATFORM_WRITER | |
CheckpointJob | PLATFORM_WRITER | force(), WAL, remap |
HomeostaticDecayJob | PLATFORM_WRITER | Graph weight updates |
GraphEnrichmentJob persist half | PLATFORM_WRITER | Enrichment LLM stays VIRTUAL |
forkJoinAll / forkJoin2 hybrid search | unchanged | StructuredTaskScope — not this SPI |
Mixed-plane split (required)¶
AsyncEntityExtractionQueue becomes a two-stage pipeline:
flowchart LR
submit["submit(text)"] --> extractQueue["extractQueue (VIRTUAL)"]
extractQueue -- "on success" --> mutationQueue["mutationQueue (PLATFORM_WRITER) { PostIngestSync }"] Same pattern for GraphEnrichmentJob and RemDreamJob: I/O on VIRTUAL, persist on PLATFORM_WRITER. Circadian volume-trigger ConcurrentTasks.fireAndForget(this::reflect) moves to the writer executor.
2. SpectorExecutorProvider — minimal, versioned SPI¶
Deliberately smaller than the 2026-08-25 interface. One executor lookup. No ThreadFactory. No watchdog. Drain is explicit and timed.
/**
* Host-provided thread provisioning. The memory library NEVER creates
* threads for dispatched work; it only submits Runnables/Callables.
*
* <p>Implementations MUST be safe to call from many threads. The host
* MAY return the same executor for repeated (plane, name) pairs.
*
* <p>Extend {@link AbstractExecutorProvider} when adding methods so
* existing hosts keep binary compatibility.
*/
public interface SpectorExecutorProvider {
/**
* Executor for {@code plane}. {@code name} is a stable logical pool
* id (e.g. {@code "graph-writer"}, {@code "entity-extract"},
* {@code "quartz-io"}). Hosts MAY ignore the suggestion and collapse
* names onto a shared pool; writers for the same namespace SHOULD
* share one single-thread executor.
*/
Executor executor(ThreadPlane plane, String name);
/**
* Cooperative drain of every executor this provider handed out.
* MUST NOT unmap arenas. MUST be idempotent.
*
* @return drain result; {@code timedOut=true} means the caller still
* proceeds to interrupt-and-join its own queues, then MAY
* refuse Arena.close() if policy is FAIL_CLOSED
*/
DrainResult drain(Duration budget);
/** Optional: host-visible identity for logs and JFR. */
default String describe() {
return getClass().getSimpleName();
}
}
public record DrainResult(boolean completed, int remainingTasks, Duration waited) {
public static DrainResult ok(Duration waited) {
return new DrainResult(true, 0, waited);
}
}
AbstractExecutorProvider supplies:
- memoization of
(plane, name) → Executor - a default
drain()thatshutdown()+awaitTermination()s everyExecutorServiceit created - no-op drain for executors the host does not own (Spring beans)
3. Installation and defaults (embeddable, testable)¶
Statics remain as a locator, not as an owner. This preserves ConcurrentTasks.fireAndForget() call-site shape while making the executor swappable.
public final class SpectorExecutors {
private static final AtomicReference<SpectorExecutorProvider> INSTALLED =
new AtomicReference<>();
public static void install(SpectorExecutorProvider provider) { /* once-per-runtime */ }
public static SpectorExecutorProvider current() {
SpectorExecutorProvider p = INSTALLED.get();
if (p != null) return p;
return Holder.DISCOVERED; // ServiceLoader then DefaultExecutorProvider
}
public static Executor executor(ThreadPlane plane, String name) {
return current().executor(plane, name);
}
}
Discovery order:
SpectorExecutors.installat host boot (Spring@Bean, benchmain, MCP kernel)ServiceLoader.load(SpectorExecutorProvider.class)DefaultExecutorProvider— created lazily, owned by this runtime, closed on drain
DefaultExecutorProvider (ships in spector-commons):
| Plane | Implementation |
|---|---|
VIRTUAL | Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("spector-vt-", 0).factory()) |
PLATFORM_SHARED | Executors.newFixedThreadPool(clamp(N_CPU - 1, 2, 8), Thread.ofPlatform().name("spector-pool-shared-", 0).factory()) |
PLATFORM_WRITER | ConcurrentHashMap<String, ExecutorService> of newSingleThreadExecutor(Thread.ofPlatform().name("spector-pool-" + name + "-", 0).factory()) |
This is what the in-process MCP kernel and tests use. Standalone bench may wrap it with a ThreadMonitor. Spring never uses it for production threads.
4. Host implementations¶
Spring Boot 4.1 (spector-synapse)¶
@Configuration
public class SynapseExecutorConfig {
@Bean
SpectorExecutorProvider spectorExecutorProvider(
@Qualifier("spectorSharedPool") ThreadPoolTaskExecutor shared,
@Qualifier("spectorWriterPool") ThreadPoolTaskExecutor writer,
@Qualifier("spectorVirtualExecutor") AsyncTaskExecutor virtual) {
var provider = new SpringExecutorProvider(shared, writer, virtual);
SpectorExecutors.install(provider);
return provider;
}
@Bean(name = "spectorSharedPool")
ThreadPoolTaskExecutor spectorSharedPool() {
var ex = new ThreadPoolTaskExecutor();
int n = Math.max(2, Math.min(8, Runtime.getRuntime().availableProcessors() - 1));
ex.setCorePoolSize(n);
ex.setMaxPoolSize(n);
ex.setQueueCapacity(256);
ex.setThreadNamePrefix("spector-pool-shared-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(10);
return ex;
}
@Bean(name = "spectorWriterPool")
ThreadPoolTaskExecutor spectorWriterPool() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(1);
ex.setMaxPoolSize(1);
ex.setQueueCapacity(1024);
ex.setThreadNamePrefix("spector-pool-writer-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(15);
return ex;
}
@Bean(name = "spectorVirtualExecutor")
AsyncTaskExecutor spectorVirtualExecutor() {
var ex = new SimpleAsyncTaskExecutor("spector-vt-");
ex.setVirtualThreads(true);
ex.setTaskTerminationTimeout(Duration.ofSeconds(10).toMillis());
return ex;
}
}
Rules for the Spring provider:
executor(PLATFORM_WRITER, name)may return the single writer bean (one JVM writer) or a memoized per-namespace single-thread executor registered as inner beans. Default: one writer for the process unlessspector.threads.writer-per-namespace=true.- Do not set
CallerRunsPolicyon these pools. Caller-runs plus queueCALLER_RUNSis how mmap work leaks onto a Tomcat/virtual request thread. Reject or block at the queue. - Delete Synapse-local
Executors.newVirtualThreadPerTaskExecutor()inMemoryService,MemoryAccessObject,ConversationReflector. Route throughSpectorExecutors.executor(VIRTUAL, "synapse-io"). - If Synapse injects a Spring
Scheduler, that scheduler'sTaskExecutorMUST beSpectorQuartzThreadPool(below), notSimpleAsyncTaskExecutoralone.
Standalone (spector-bench, CLI)¶
StandaloneExecutorProvider extends DefaultExecutorProvider and optionally registers a ThreadMonitor that samples platform pool threads for SLA breaches. Monitoring stays out of the library.
Tests¶
TestExecutorProvider returns Executors.newSingleThreadExecutor() for every plane (deterministic sequencing) or the calling thread (MoreExecutors.directExecutor equivalent) for unit tests that must not hop.
5. ConcurrentTasks after migration¶
Keep the public surface. Change the sink.
public static void fireAndForget(Runnable task) {
fireAndForget(ThreadPlane.VIRTUAL, "fire-forget", task);
}
public static void fireAndForget(ThreadPlane plane, String pool, Runnable task) {
SpectorExecutors.executor(plane, pool).execute(() -> {
try {
task.run();
} catch (Exception e) {
log.log(System.Logger.Level.WARNING, "Fire-and-forget task failed", e);
}
});
}
public static void fireAndForget(String sessionId, String namespaceId, Runnable task) {
fireAndForget(ThreadPlane.VIRTUAL, "fire-forget",
() -> MemoryScope.runWithScope(sessionId, namespaceId, task));
}
Unchanged:
forkJoinAll/forkJoin2/forkRunAllviaStructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())- Classic fallback behind
-Dspector.concurrency.structured=falsestill uses a method-scopednewVirtualThreadPerTaskExecutor()inside try-with-resources — not the global singleton - Remove
FIRE_FORGET_EXECUTOR,virtualExecutor()as a shared owner, andshutdown()/shutdownNow()as process-global hammers. Replacement:SpectorExecutors.current().drain(budget)
ScopedValue / MemoryScope must be rebound at every hop (MemoryScope.runWithScope) because neither virtual threads nor platform pool threads inherit caller ScopedValue bindings unless the hop is a StructuredTaskScope fork from a bound caller. Queue workers always restore from ScopedTask.
6. SpectorTaskQueue — Model B (submit, do not start)¶
The queue stops being a thread owner. It is a bounded priority buffer + retry + metrics + drain.
public SpectorTaskQueue(
String name,
TaskQueueConfig config,
TaskHandler<T> handler,
Predicate<ScopedTask<T>> cancellationCheck,
Executor workerExecutor) { ... }
Worker loop is submitted config.parallelism() times onto workerExecutor. For PLATFORM_WRITER queues, parallelism is forced to 1 by config validation.
Bounded buffer and backpressure¶
Replace PriorityBlockingQueue + size() with a bounded structure:
PriorityBlockingQueuefor ordering, plus aSemaphore(capacity)(or a custom bounded priority queue)offeronly aftertryAcquire;releasewhen a task leaves the buffer (processed, dropped, or rejected after dequeue)
public enum BackpressurePolicy {
REJECT_FAST, // existing
DROP_OLDEST, // existing, but drop the oldest by enqueue sequence, NOT poll()
CALLER_RUNS, // existing; FORBIDDEN on PLATFORM_WRITER queues
BLOCK // new: Condition.await until permit or close
}
DROP_OLDEST walks by ScopedTask.sequence() (monotonic enqueue id already used as FIFO tiebreak) and evicts the minimum sequence, not the priority head.
BLOCK semantics:
- Caller waits on a
Conditionbound to the semaphore - Waiting is interruptible; interrupt → reject + restore interrupt status
- Safe on virtual submitters (unmounts) and on platform submitters
- Recommended default for
PLATFORM_WRITERgraph queues
CALLER_RUNS is rejected at config time when config.plane() == PLATFORM_WRITER.
Batch drain¶
public static TaskQueueConfig ofBatched(
ThreadPlane plane, int capacity, int parallelism, int batchDrainSize) { ... }
@FunctionalInterface
public interface BatchTaskHandler<T> {
void handleBatch(List<ScopedTask<T>> tasks) throws Exception;
}
When batchDrainSize > 1, the worker drains up to N tasks, then invokes the batch handler once. EagerConsolidator and PostIngestSync take one StampedLock.writeLock() per batch.
Existing TaskQueueConfig.of(capacity, parallelism) remains; plane defaults to VIRTUAL for binary compatibility, but memory construction sites pass an explicit plane.
Queue close¶
close():
closed = true
signal BLOCK waiters
interrupt idle polls
wait up to drainTimeoutMs for in-flight handler/batch to return
do NOT interrupt a thread that is inside handle()/handleBatch()
remaining tasks: count as failed, do not run after close
unregister from TaskQueueManager
Workers are executor tasks; joining them is "no in-flight handler and buffer empty or timed out," not Thread.join.
7. DaemonSupervisor¶
Keep restart policy, cycle watchdog, and DaemonStatus. Those are task supervision, not pool monitoring.
Change only construction:
public DaemonSupervisor(String prefix, SpectorExecutorProvider executors) { ... }
public void schedule(String name, Runnable task, Duration interval,
DaemonPolicy policy, ThreadPlane plane) {
executors.executor(plane, "daemon-" + prefix + "-" + name)
.execute(this::loop); // loop checks running flag and sleeps
}
Default plane for checkpoint / decay / reflect daemons: PLATFORM_WRITER. Do not use virtual threads for cycles that call force() or take write locks.
Interrupt + 5s join-equivalent stays, but must respect the shared close budget (section 10).
8. Quartz — first-class peer of the SPI¶
Quartz is how Spector schedules periodic cognition. It must not be a side door back onto a global virtual executor.
Problem with VirtualThreadPool today¶
public boolean runInThread(Runnable runnable) {
executor.execute(runnable); // always VIRTUAL
}
public int blockForAvailableThreads() { return 1000; }
public int getPoolSize() { return Integer.MAX_VALUE; }
public void shutdown(boolean wait) { running = false; } // does not drain
CheckpointJob, SleepConsolidationJob, HomeostaticDecayJob, GraphEnrichmentJob all ride virtual threads. DisallowConcurrentExecution prevents overlap of the same job key, not cross-job writer collisions on the same StampedLock.
SpectorQuartzThreadPool¶
Replace VirtualThreadPool as the production org.quartz.spi.ThreadPool. Keep VirtualThreadPool as a deprecated adapter that delegates to SpectorQuartzThreadPool with plane VIRTUAL for one release.
/**
* Quartz ThreadPool that routes each job onto the Spector plane
* declared by the job. Quartz itself keeps a small platform thread
* for trigger firing; we only own job execution.
*/
public final class SpectorQuartzThreadPool implements org.quartz.spi.ThreadPool {
public static final String JOB_DATA_PLANE = "spector.threadPlane";
public static final String JOB_DATA_POOL = "spector.poolName";
private final SpectorExecutorProvider provider;
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicBoolean accepting = new AtomicBoolean(true);
@Override
public boolean runInThread(Runnable runnable) {
if (!accepting.get() || runnable == null) return false;
ThreadPlane plane = ThreadPlane.VIRTUAL;
String pool = "quartz";
if (runnable instanceof PlaneAware planeAware) {
plane = planeAware.plane();
pool = planeAware.poolName();
}
inFlight.incrementAndGet();
try {
provider.executor(plane, pool).execute(() -> {
try {
runnable.run();
} finally {
inFlight.decrementAndGet();
}
});
return true;
} catch (RuntimeException e) {
inFlight.decrementAndGet();
return false;
}
}
@Override
public int blockForAvailableThreads() {
// Writer plane is serial: report 1 so Quartz throttles misfires
// instead of believing it has 1000 free workers.
return Math.max(1, writerAvailability());
}
@Override
public void shutdown(boolean waitForJobsToComplete) {
accepting.set(false);
if (waitForJobsToComplete) {
// wait until inFlight == 0 or the close budget expires
}
}
}
Job classification (normative):
| Job | Plane | Pool name | Concurrent? |
|---|---|---|---|
SleepConsolidationJob | PLATFORM_WRITER | quartz-writer | @DisallowConcurrentExecution |
CheckpointJob | PLATFORM_WRITER | quartz-writer | same |
HomeostaticDecayJob | PLATFORM_WRITER | quartz-writer | same |
GraphEnrichmentJob persist | PLATFORM_WRITER | quartz-writer | same |
GraphEnrichmentJob LLM extract | VIRTUAL | quartz-io | split inside the job: extract async, persist via writer queue |
RemDreamJob | VIRTUAL then writer persist | split | |
DmnWanderingJob | VIRTUAL if it calls LLM; else PLATFORM_SHARED | quartz-io / quartz-shared |
Mechanism: a {@code @OnPlane(ThreadPlane, pool)} annotation on the Job class, read by a Quartz JobListener / wrapper PlaneAwareRunnable. QuartzMemoryScheduler.scheduleJobInternal also writes JOB_DATA_PLANE into the JobDataMap so reflectively built jobs cannot forget.
Standalone path (resolveDefaultStandaloneScheduler):
Executor ignored /* do not take a raw Executor */;
var pool = new SpectorQuartzThreadPool(SpectorExecutors.current());
pool.setInstanceName(DEFAULT_STANDALONE_SCHEDULER_NAME);
factory.createScheduler(name, id, pool, new RAMJobStore());
Spring path: SchedulerFactoryBean.setTaskExecutor is not sufficient (one executor for all jobs). Set the Quartz org.quartz.threadPool.class to SpectorQuartzThreadPool and inject the provider through a SchedulerContext. Alternatively wrap SchedulerFactoryBean with a SchedulerFactoryBeanCustomizer that installs the SPI pool.
Writer jobs that mutate the same namespace share the same PLATFORM_WRITER executor, so Quartz-fired checkpoint and sleep-consolidation cannot interleave slab writes even when job keys differ.
SpectorQuartzThreadPool.shutdown(true) participates in the close budget and is awaited before arena unmap.
9. Thread naming (grep-able in logs, dumps, JFR)¶
| Kind | Pattern | Example |
|---|---|---|
| Shared platform worker | spector-pool-shared-{n} | spector-pool-shared-2 |
| Writer platform worker | spector-pool-writer-{ns}-{n} | spector-pool-writer-default-0 |
| Virtual dispatch | spector-vt-{pool}-{n} | spector-vt-entity-extract-4 |
| Queue task (virtual or platform) | name comes from the pool, not the queue | — |
| Daemon loop | spector-daemon-{prefix}-{name} | spector-daemon-memory-checkpoint |
| Quartz job execution | same as the plane pool it routed onto | — |
DaemonSupervisor today emits spector-{prefix}-{name}. Migrate to spector-daemon-{prefix}-{name} in the same change so dumps match this table.
10. Shutdown protocol — happens-before for Arena.close()¶
One budget owned by the host. Default 15s Spring / 10s standalone. Components share it; they do not each take 5s.
flowchart TD
start["DefaultSpectorMemory.close() / PersistenceManager.close()"]
step0["0. closed.set(true) on the façade\nRemember/Recall/submit paths fail fast"]
step1["1. QuartzMemoryScheduler.standby()\nSpectorQuartzThreadPool stops accepting\nin-flight jobs finish or hit remaining budget"]
step2["2. DaemonSupervisor: running=false, interrupt sleeps,\nwait for current cycle (never interrupt inside force())"]
step3["3. SpectorTaskQueue.close() for extract, mutation,\neager-consolidation (finish in-flight batch only)"]
step4["4. SpectorExecutorProvider.drain(remaining budget)\nSpring: TaskExecutor shutdown + awaitTermination\nStandalone: ExecutorService.shutdown + awaitTermination"]
step5["5. MemorySegment.force() dirty pages"]
step6["6. Close MemoryBundles / Arenas\nIf drain timed out AND fail-closed policy:\nlog + refuse close (leave arenas mapped) OR\nleak-on-purpose with a fatal metric\nDefault policy: FAIL_OPEN after budget, log remaining\nin-flight count, then unmap — only acceptable if\nstep 0-3 made new segment access impossible"]
step7["7. Host-level SmartLifecycle / @PreDestroy complete"]
start --> step0
step0 --> step1
step1 --> step2
step2 --> step3
step3 --> step4
step4 --> step5
step5 --> step6
step6 --> step7 Invariants:
- No component calls
ConcurrentTasks.shutdown()(method removed). TaskQueueManager.closeAll()is not used as a process-wide hammer from a single namespace close.- Multi-tenant Synapse: drain is per memory instance for queues/daemons/Quartz job group; process executors drain only on application stop.
force()happens only after writers have stopped.
11. Observability split (SOLID: I + S)¶
| Signal | Owner | Channel |
|---|---|---|
| Queue depth, processed, failed, retry, latency | Library QueueMetrics | already bound by TaskQueueMetricsBinder |
| Daemon state, last cycle, restarts | Library DaemonStatus | Cortex / Actuator |
| Pool size, active, queue remaining, rejected | Host | Micrometer TaskExecutor metrics |
| Hung platform thread | Host ThreadMonitor (bench) | logs |
| In-flight Quartz jobs | SpectorQuartzThreadPool.inFlight | gauge |
The library does not grow a watchdog for host pools. The host does not reimplement queue metrics.
12. Design principles applied¶
- SRP — provider, queue, supervisor, Quartz adapter, monitor are four types.
- OCP — new hosts implement
SpectorExecutorProvider; new planes require an enum + provider mapping only;AbstractExecutorProviderabsorbs SPI growth. - LSP — any provider is substitutable; tests use a direct/single-thread provider.
- ISP — providers do not implement queue or Quartz APIs; Quartz sees
ThreadPoolonly. - DIP — memory depends on the SPI abstraction; Spring and bench depend on the SPI and supply the concrete.
- Strategy —
BackpressurePolicy,DaemonPolicy, host provider. - Adapter —
SpectorQuartzThreadPooladapts Quartz to the SPI. - Template method —
AbstractExecutorProvider.drain. - Facade —
SpectorExecutors+ConcurrentTaskskeep call sites stable. - Single-writer — one platform thread per guarded structure (or per namespace).
13. JDK 25 practices encoded as review rules¶
- No
synchronizedon paths that may run on virtual threads. - No pinning constructs (
Object.waiton hot paths);ReentrantLock/StampedLock/Semaphore. StructuredTaskScopeonly for sibling fan-out with a bounded lifetime. Not for daemons, not for queues, not for Quartz.- Rebind
MemoryScopeat every executor hop. - Never close an
Arenawhile a writer executor still has a task that captured aMemorySegment. - Virtual threads are not a substitute for a work queue. Unbounded VT dispatch of CPU work is a defect.
Thread.ofPlatform().factory()for writer/shared pools so JFR thread names survive.
Migration inventory¶
Not "~15 memory call sites." Treat this list as the definition of done.
spector-commons¶
ConcurrentTasks— remove global executor; route fire-and-forget; delete process-globalshutdownSpectorTaskQueue— Model B, bounded buffer,BLOCK, correctDROP_OLDEST, batch drain, plane on configTaskQueueConfig/BackpressurePolicyDaemonSupervisor— executor-submitted loops, namingVirtualThreadPool— deprecate; addSpectorQuartzThreadPoolConsolidationRelay— classify the action; defaultVIRTUAL- New:
ThreadPlane,SpectorExecutorProvider,AbstractExecutorProvider,DefaultExecutorProvider,SpectorExecutors,DrainResult,@OnPlane
spector-events¶
EventBusasync path →VIRTUAL
spector-memory¶
AsyncEntityExtractionQueue— split extract / syncEagerConsolidator—PLATFORM_WRITER,ofBatched,BLOCKDefaultSpectorMemorycircadian trigger — writer executor, notfireAndForget(reflect)QuartzMemoryScheduler— installSpectorQuartzThreadPool; stamp job plane inJobDataMap- Jobs:
CheckpointJob,SleepConsolidationJob,HomeostaticDecayJob,GraphEnrichmentJob,RemDreamJob,DmnWanderingJob SpectorMemoryBuilder—executorProvider(...); stop taking a rawExecutoras the Quartz sinkDefaultSpectorMemory.close/PersistenceManager— section 10 orderDaemonSupervisorBuilder— pass provider + planes
spector-synapse¶
SynapseExecutorConfigbeans +SpectorExecutors.install- Remove extra VT executors in
MemoryService,MemoryAccessObject,ConversationReflector - Quartz
Schedulerbean usesSpectorQuartzThreadPool - Actuator gauges for writer queue depth and Quartz
inFlight
spector-bench / CLI / MCP kernel¶
- Install
DefaultExecutorProviderorStandaloneExecutorProviderat process start - Optional
ThreadMonitor
Tests¶
SpectorTaskQueueTest, stress tests,VirtualThreadPoolTest,QuartzMemorySchedulerTest,MemoryScopeTestfire-and-forget scope propagation- New: drain-before-arena test that fails if a writer touches a segment after step 5
- New: plane-routing test for Quartz jobs
6. Pros and Cons of the Options¶
| Option | Pros | Cons |
|---|---|---|
| Option 1: Status Quo | Zero refactoring | Cache thrashing, lock stampede, arena close races, host thread fights |
| Option 2: Library ThreadManager | Centralized in library | Still violates host ownership; Spring/synapse thread lifecycle conflict |
| Option 3: Executor SPI & Dual-Plane | Host owns pools, zero raw threads, deterministic arena drain | Requires migration across commons, events, memory, and synapse |
7. Implementation Plan¶
- SPI types +
DefaultExecutorProvider+SpectorExecutors.install. PointfireAndForgetat it. No behavior change if default is virtual. - Bounded
SpectorTaskQueue,BLOCK, correctDROP_OLDEST, batch API. InjectExecutor. - Split extract vs sync; move
EagerConsolidatorandreflect()ontoPLATFORM_WRITER. SpectorQuartzThreadPool+ job annotations + standalone/Spring wiring.- Close protocol in
DefaultSpectorMemory/PersistenceManagerwith a single budget. - Synapse provider; delete host-local VT executors.
- Naming migration + metrics + drain-before-arena test.
- Remove deprecated
VirtualThreadPoolno-arg constructor andConcurrentTasks.virtualExecutor()in the following minor.
8. Code Reference & Verification¶
Positive¶
- Spring, standalone, MCP, and tests share one contract. New hosts implement one interface.
- Writer serialization is structural (single-thread executor + batch lock), not accidental (
DisallowConcurrentExecutionon one job key). - Quartz can no longer schedule a checkpoint
force()onto a virtual carrier while extract workers hold the same slab. Arena.close()has a documented happens-before. The close race becomes a testable invariant.- Call sites stay familiar (
fireAndForget,TaskQueueConfig.of) while the sink becomes injectable. - Pool sizing and graceful shutdown become host configuration — where they belong.
Negative¶
- Real migration across commons, events, memory, synapse, bench — larger than the original estimate.
- Hosts can still mis-map planes. Mitigation: writer queues reject
VIRTUALexecutors whenconfig.plane() == PLATFORM_WRITERif the provider exposesThread.currentThread().isVirtual()on a probe task at bind time (debug/assert mode). - SPI growth still requires host updates. Mitigation:
AbstractExecutorProviderdefault methods. - A single process-wide writer can serialize unrelated namespaces. Mitigation:
writer-per-namespaceflag. - Fail-open unmap after a drain timeout can still throw if a leaked task ignores the façade closed flag. Mitigation: segment access checks a generation stamp on
RuntimeBundle.
Risks and non-goals¶
- Non-goal: replacing Quartz. We route its execution; we do not rewrite scheduling.
- Non-goal: a library-level thread dump / watchdog UI.
- Risk:
BLOCKon a writer queue invoked from the writer thread → deadlock. Rule: writer-plane tasks never submitBLOCKwork to the same writer pool. Extract→sync handoff usesREJECT_FASTor an internal unbounded handoff bounded by extract parallelism. - Risk: structured-concurrency fallback still allocates a short-lived VT executor. Acceptable: scoped to the call, closed in try-with-resources.
Review checklist (merge gate)¶
- No
Thread.ofVirtual()/newVirtualThreadPerTaskExecutor()in production library paths exceptDefaultExecutorProviderand structured-concurrency fallback - No
SpectorTaskQueueconstructor starts a thread -
CALLER_RUNSforbidden onPLATFORM_WRITER -
DROP_OLDESTevicts by enqueue sequence - Extract and graph sync are different queues / planes
- Every Quartz job has
@OnPlane/JobDataMapplane -
SpectorQuartzThreadPool.shutdown(true)waits oninFlight -
DefaultSpectorMemory.closeorder matches section 10 - Synapse has no private VT executor
- Drain-before-arena test exists and fails if a writer runs past
force() - Thread names match the table in a thread dump of bench + Synapse
Code Reference & Verification Gate¶
- Primary Module(s):
nucleus/spector-commons,memory/spector-memory,synapse/spector-synapse - Key Packages:
com.spectrayan.spector.commons.concurrent,com.spectrayan.spector.memory.scheduler - Classes:
SpectorExecutorProvider.java,TaskQueueManager.java,SpectorTaskQueue.java,SpectorQuartzThreadPool.java,DefaultSpectorMemory.java - Verification Tests:
DualPlaneExecutorTest.java,SpectorTaskQueueBackpressureTest.java,ArenaDrainCloseTest.java