Type: bug. Effort: S. Affects all three batch ingesters.
Summary
A batch sync job can be left RUNNING for ever, with no log line of any kind, if the task throws something that is not an Exception. Two things combine to make it silent:
runJob catches Exception, which does not catch Error or any other Throwable.
- The task is launched with
CompletableFuture.runAsync(...) and the returned future is discarded, so a throwable that escapes the catch is captured into that future and never observed, never logged, never surfaced.
The job record therefore keeps status: RUNNING and completedAt: null indefinitely. A caller polling /api/sync/status/{id} waits for ever; a test harness times out and blames the source system.
Observed
A real SharePoint run against Microsoft Graph, 2026-09-21:
GET /api/sync/status/652f0d4b-...
{ "status": "RUNNING", "completedAt": null, "discoveredCount": 52, "syncedCount": 51, "failedCount": 0 }
Nine minutes after the last log line, and it never changed afterwards. Diagnosis:
- Container CPU 0.15%, so nothing was working.
- 50
Chunking produced lines and 50 Completed sync for node lines, so the per-document work had finished.
docker kill -s QUIT thread dump: the single connector-batch-1 thread is parked in LinkedBlockingQueue.take inside ThreadPoolExecutor.getTask, i.e. back in the pool waiting for new work. No thread anywhere in ConnectorBatchIngestionService, ConnectorDiscoveryService or NodeSyncService.
- No
WARN or ERROR from our code, no OutOfMemoryError, no java.lang.Error, no stack trace.
So the task ended and neither job.complete() nor job.fail() ran. With the catch (Exception) in place, a Throwable that is not an Exception is the only way to reach that state, and the discarded future is why nothing was printed.
Note the absence of an Error in the log is consistent with this rather than evidence against it: a throwable captured by a CompletableFuture nobody inspects is never printed at all.
Where
Same shape in all three:
plugin-runtime/plugin-batch-ingester ConnectorBatchIngestionService:85
alfresco/alfresco-batch-ingester BatchIngestionService:84 and :97
nuxeo/nuxeo-batch-ingester NuxeoBatchIngestionService:67 and :74
Fix
Two independent changes, and both are worth making because either alone leaves a gap:
- Observe the future.
CompletableFuture.runAsync(...).whenComplete((ignored, thrown) -> { if (thrown != null) { job.fail(); log.error("...", thrown); } }). This catches everything, including what the inner catch misses, and it is the only place that can.
- Catch
Throwable in runJob, not Exception, so the job is marked failed at the point of failure with the source context still in scope. Rethrow after marking if that is preferred for an Error.
A job left RUNNING is worse than a job marked FAILED, because the second is actionable and the first is indistinguishable from slow.
Acceptance criteria
Not in scope
What the original throwable was on that run is unknown and unknowable from here, which is the point of the issue. Once the future is observed, the next occurrence names itself.
Type: bug. Effort: S. Affects all three batch ingesters.
Summary
A batch sync job can be left
RUNNINGfor ever, with no log line of any kind, if the task throws something that is not anException. Two things combine to make it silent:runJobcatchesException, which does not catchErroror any otherThrowable.CompletableFuture.runAsync(...)and the returned future is discarded, so a throwable that escapes the catch is captured into that future and never observed, never logged, never surfaced.The job record therefore keeps
status: RUNNINGandcompletedAt: nullindefinitely. A caller polling/api/sync/status/{id}waits for ever; a test harness times out and blames the source system.Observed
A real SharePoint run against Microsoft Graph, 2026-09-21:
Nine minutes after the last log line, and it never changed afterwards. Diagnosis:
Chunking producedlines and 50Completed sync for nodelines, so the per-document work had finished.docker kill -s QUITthread dump: the singleconnector-batch-1thread is parked inLinkedBlockingQueue.takeinsideThreadPoolExecutor.getTask, i.e. back in the pool waiting for new work. No thread anywhere inConnectorBatchIngestionService,ConnectorDiscoveryServiceorNodeSyncService.WARNorERRORfrom our code, noOutOfMemoryError, nojava.lang.Error, no stack trace.So the task ended and neither
job.complete()norjob.fail()ran. With thecatch (Exception)in place, aThrowablethat is not anExceptionis the only way to reach that state, and the discarded future is why nothing was printed.Note the absence of an
Errorin the log is consistent with this rather than evidence against it: a throwable captured by aCompletableFuturenobody inspects is never printed at all.Where
Same shape in all three:
plugin-runtime/plugin-batch-ingesterConnectorBatchIngestionService:85alfresco/alfresco-batch-ingesterBatchIngestionService:84and:97nuxeo/nuxeo-batch-ingesterNuxeoBatchIngestionService:67and:74Fix
Two independent changes, and both are worth making because either alone leaves a gap:
CompletableFuture.runAsync(...).whenComplete((ignored, thrown) -> { if (thrown != null) { job.fail(); log.error("...", thrown); } }). This catches everything, including what the inner catch misses, and it is the only place that can.ThrowableinrunJob, notException, so the job is marked failed at the point of failure with the source context still in scope. Rethrow after marking if that is preferred for anError.A job left
RUNNINGis worse than a job markedFAILED, because the second is actionable and the first is indistinguishable from slow.Acceptance criteria
Errorleaves the jobFAILED, notRUNNING, and logs the throwable with the job id.Throwablethat is neitherExceptionnorError.Not in scope
What the original throwable was on that run is unknown and unknowable from here, which is the point of the issue. Once the future is observed, the next occurrence names itself.