@@ -90,11 +90,15 @@ public function findOldestScheduledByType(array $taskTypes, array $taskIdsToIgno
9090 * followed by a guarded UPDATE to RUNNING. Concurrent workers skip rows already
9191 * locked by another transaction, so no two workers ever claim the same task.
9292 *
93- * SQLite does not support SKIP LOCKED (verified: Doctrine throws "Operation
94- * 'SKIP LOCKED' is not supported by platform"), so we feature-detect via the DB
95- * provider and fall back to the existing bounded {@see lockTask} retry, which is
96- * still safe because the UPDATE ... WHERE status = SCHEDULED is itself atomic and
97- * SQLite serialises writers.
93+ * Two databases cannot use the SKIP LOCKED path and fall back to a bounded
94+ * lock-and-retry claim instead:
95+ * - SQLite has no SKIP LOCKED (Doctrine throws "Operation 'SKIP LOCKED' is not
96+ * supported by platform").
97+ * - Oracle cannot combine a row-limiting clause with FOR UPDATE: the LIMIT is
98+ * emulated with a ROWNUM sub-select, and selecting FOR UPDATE from that derived
99+ * view raises ORA-02014.
100+ * The fallback is still safe because the UPDATE ... WHERE status = SCHEDULED is itself
101+ * atomic (SQLite additionally serialises writers).
98102 *
99103 * A task is only ever transitioned SCHEDULED -> RUNNING here; it is never marked
100104 * FAILED by claiming. If the task cannot be claimed (none scheduled, or it was
@@ -105,8 +109,10 @@ public function findOldestScheduledByType(array $taskTypes, array $taskIdsToIgno
105109 * @throws Exception
106110 */
107111 public function claimOldestScheduledTask (array $ taskTypes ): ?Task {
108- if ($ this ->db ->getDatabaseProvider () === IDBConnection::PLATFORM_SQLITE ) {
109- // SKIP LOCKED is unsupported on SQLite: fall back to the bounded lock-and-retry claim.
112+ $ provider = $ this ->db ->getDatabaseProvider ();
113+ // SKIP LOCKED is unusable on SQLite (unsupported) and Oracle (LIMIT + FOR UPDATE =>
114+ // ORA-02014): both fall back to the bounded lock-and-retry claim.
115+ if ($ provider === IDBConnection::PLATFORM_SQLITE || $ provider === IDBConnection::PLATFORM_ORACLE ) {
110116 return $ this ->claimWithBoundedRetry ($ taskTypes );
111117 }
112118
@@ -184,7 +190,7 @@ private function claimWithSkipLocked(array $taskTypes): ?Task {
184190 }
185191
186192 /**
187- * Fallback claim for databases without SKIP LOCKED (SQLite).
193+ * Fallback claim for databases that cannot use the SKIP LOCKED path (SQLite, Oracle ).
188194 *
189195 * Repeatedly fetches the oldest scheduled task and attempts the atomic
190196 * UPDATE ... WHERE status = SCHEDULED. Tasks lost to another worker are added to a
@@ -207,19 +213,9 @@ private function claimWithBoundedRetry(array $taskTypes): ?Task {
207213 }
208214
209215 if ($ this ->lockTask ($ task ) !== 0 ) {
210- $ task ->setStatus (\OCP \TaskProcessing \Task::STATUS_RUNNING );
211- // Record the start time at claim time. lockTask only flips the status (and is
212- // shared with other callers), so persist started_at with a targeted follow-up
213- // UPDATE rather than changing lockTask's behaviour. The worker receives the task
214- // already RUNNING, so Manager::setTaskStatus would otherwise never write it.
215- $ startedAt = $ this ->timeFactory ->now ()->getTimestamp ();
216- $ update = $ this ->db ->getQueryBuilder ();
217- $ update ->update ($ this ->tableName )
218- ->set ('started_at ' , $ update ->createPositionalParameter ($ startedAt , IQueryBuilder::PARAM_INT ))
219- ->where ($ update ->expr ()->eq ('id ' , $ update ->createPositionalParameter ($ task ->getId (), IQueryBuilder::PARAM_INT )));
220- $ update ->executeStatement ();
221- $ task ->setStartedAt ($ startedAt );
222- return $ task ;
216+ // lockTask atomically flipped SCHEDULED -> RUNNING and stamped started_at.
217+ // Re-read so the returned task reflects the persisted status and started_at.
218+ return $ this ->find ($ task ->getId ());
223219 }
224220
225221 // Another worker took it; skip this id and try the next oldest.
@@ -376,12 +372,25 @@ public function update(Entity $entity): Entity {
376372 return parent ::update ($ entity );
377373 }
378374
375+ /**
376+ * Atomically claim a task by transitioning it SCHEDULED -> RUNNING.
377+ *
378+ * The UPDATE is guarded on `status = SCHEDULED` so a task another worker has already
379+ * finished (SUCCESSFUL/FAILED) between a caller's SELECT and this UPDATE can never be
380+ * re-claimed and processed twice. started_at is stamped in the same statement: the
381+ * worker receives the task already RUNNING, so the later SCHEDULED -> RUNNING edge in
382+ * Manager::setTaskStatus (which used to set started_at) no longer fires.
383+ *
384+ * @return int Number of rows updated: 1 if the task was claimed, 0 if it was no longer scheduled.
385+ */
379386 public function lockTask (Entity $ entity ): int {
387+ $ startedAt = $ this ->timeFactory ->now ()->getTimestamp ();
380388 $ qb = $ this ->db ->getQueryBuilder ();
381389 $ qb ->update ($ this ->tableName )
382390 ->set ('status ' , $ qb ->createPositionalParameter (\OCP \TaskProcessing \Task::STATUS_RUNNING , IQueryBuilder::PARAM_INT ))
391+ ->set ('started_at ' , $ qb ->createPositionalParameter ($ startedAt , IQueryBuilder::PARAM_INT ))
383392 ->where ($ qb ->expr ()->eq ('id ' , $ qb ->createPositionalParameter ($ entity ->getId (), IQueryBuilder::PARAM_INT )))
384- ->andWhere ($ qb ->expr ()->neq ('status ' , $ qb ->createPositionalParameter (2 , IQueryBuilder::PARAM_INT )));
393+ ->andWhere ($ qb ->expr ()->eq ('status ' , $ qb ->createPositionalParameter (\ OCP \ TaskProcessing \Task:: STATUS_SCHEDULED , IQueryBuilder::PARAM_INT )));
385394 try {
386395 return $ qb ->executeStatement ();
387396 } catch (Exception ) {
0 commit comments