Siyao Meng created HDDS-17390:
---------------------------------

             Summary: ExportJobManager registers an export task only after 
handing it to the worker, so cancelJob and the worker's cleanup can miss it
                 Key: HDDS-17390
                 URL: https://issues.apache.org/jira/browse/HDDS-17390
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: MC-1-register-export-task-before-job-is-visible.patch

h3. Mechanism
{{ExportJobManager.submitJob}} adds the new job to {{jobTracker}} and 
{{jobQueue}} under the {{jobQueue}} lock, releases the lock, hands the task to 
the single worker with {{workerPool.submit}} and only then stores the returned 
{{Future}} in {{runningTasks}}. Two methods rely on that entry: {{cancelJob}} 
removes it and calls {{cancel(true)}} to stop a QUEUED or RUNNING job, and 
{{executeExport}} removes it in its {{finally}} block. Between the hand-off and 
the registration, each can miss the other.

* A {{cancelJob}} in that interval finds no entry, so nothing cancels or 
interrupts the task. It still marks the job FAILED with "Cancelled by user", 
deletes the files the job has so far, removes the job from {{jobTracker}} and 
returns normally. The task then runs unchanged: {{executeExport}} sets RUNNING, 
writes the CSV files and the tar archive at the job's file path and sets 
COMPLETED. The job is no longer in {{jobTracker}}, so no method of the manager 
refers to that archive any more. It stays in the export directory until the 
startup cleanup in the {{ExportJobManager}} constructor runs at the next Recon 
start.
* If the worker runs the whole task, including the {{finally}} removal, before 
{{submitJob}} reaches the registration, the registration adds an entry for a 
task that has already finished. Nothing removes it later: {{cancelJob}} touches 
{{runningTasks}} only for QUEUED or RUNNING jobs, {{shutdown}} does not clear 
the map, and job IDs are random UUIDs that are never reused.

h3. Trigger
Both are interleavings inside {{submitJob}}, between {{workerPool.submit}} and 
{{runningTasks.put}}:
# {{cancelJob}} for the new job runs in that interval, before the worker has 
created the job directory ({{Files.createDirectories}} at the top of 
{{executeExport}}). If the worker has already created the job directory, no 
archive is left: {{cancelJob}} deletes that directory and then the tar path, so 
the worker either fails on the missing directory or its finished tar is deleted 
(code reading, not run).
# The worker runs the whole job before the submitting thread reaches 
{{runningTasks.put}}. This requires the submitting thread to be descheduled for 
the length of a complete export, for example a small one on a busy host.

Both windows are short. The tests below fix the order of the steps.

h3. Impact
* A {{cancelJob}} that returns normally does not stop the export. The job 
object ends COMPLETED with the error message "Cancelled by user", and its tar 
archive stays in the export directory with no job referring to it until Recon 
restarts. The worker spends the full export time on the cancelled job, and jobs 
submitted after it wait for it.
* Every occurrence of the second interleaving leaves one {{runningTasks}} entry 
(a job ID and a finished {{FutureTask}}) for the life of the process. Nothing 
reads it, so no caller gets a wrong result.

h3. Reproduction
Three regression tests in the attached patch, in the existing 
{{TestExportJobManager}} ({{hadoop-ozone/recon}}), fail on unmodified source. 
All three use reflection to replace the manager's worker pool with one that 
runs each task on the submitting thread at the moment {{submitJob}} hands it 
over. This fixes the order of steps that otherwise race between threads, and no 
{{ExportJobManager}} code is changed. The database layer is the suite's Mockito 
mock. This issue has no separate standalone reproduction; the regression tests 
are the reproduction.
* {{cancelBeforeTaskReachesWorkerStopsExport}} calls {{cancelJob}} at the 
hand-off and expects the export directory to stay empty. Without the change it 
fails because the archive is there.
* {{taskFinishingBeforeSubmitReturnsIsNotLeftRegistered}} lets the task finish 
before {{submitJob}} continues and expects {{runningTasks}} to be empty. 
Without the change it fails because the finished task is still registered.
* {{cancelBeforeJobDirectoryExistsStopsEmptyExport}} covers the related case 
described under Patch: it calls {{cancelJob}} from inside the running task for 
a state with no rows, then recreates the job directory as if the cancel had 
come before {{executeExport}} created it, and expects the export directory to 
stay empty. Without the change it fails because the archive is there.

{noformat}
cancelBeforeTaskReachesWorkerStopsExport:
  to be an empty directory but it contained:
  [.../export_missing_1792751293225.tar]
taskFinishingBeforeSubmitReturnsIsNotLeftRegistered:
  Expecting empty but was: 
{"380046c3-9230-4b3a-92b3-a7937fa67a16"=FutureTask[Completed: null]}
cancelBeforeJobDirectoryExistsStopsEmptyExport:
  to be an empty directory but it contained:
  [.../export_missing_1792753300334.tar]
{noformat}

h3. Patch
[^MC-1-register-export-task-before-job-is-visible.patch], against 
fb2d0310a9c6ea37c72cefb5cc1d5b4db35a7ab1. It also applies to master at 
9c3e5477ffb.

{{submitJob}} now creates the {{FutureTask}} itself and stores it in 
{{runningTasks}} inside the locked block, just before the job is added to 
{{jobTracker}}. It hands the task to the worker with {{workerPool.execute}} 
after the lock is released. So a {{cancelJob}} that finds the job also finds 
its task: a task cancelled before it starts never runs, and a running task is 
interrupted as before. The worker's {{finally}} removal always comes after the 
registration. {{submit}} already wrapped the task in a {{FutureTask}}, so the 
task runs exactly as before. Job states and the queue checks are unchanged.

The patch also adds the cursor loop's interrupt check once more, right before 
{{Archiver.create}}. The CSV and tar writes do not observe an interrupt 
({{Files.newOutputStream}} is not interruptible on current JDKs), so before 
this check nothing stopped an interrupted export of a state with no rows. If 
{{cancelJob}} interrupted such a task after it had started but before it had 
created its job directory, the worker created the directory and wrote the 
archive after {{cancelJob}} had deleted both paths, and the archive was left. 
With the check, {{cancelJob}} interrupts before it deletes anything, so the 
flag is already set when the worker reaches the check. The worker then marks 
the job FAILED and deletes its files. Past the check, the deleted directory and 
tar path stop the export as described under Trigger. What remains is that same 
early window combined with a database driver that clears the interrupt flag 
during the count query without failing (code reading, not run against a real 
database).

With the patch, {{TestExportJobManager}} (17 tests) passes and checkstyle on 
{{hadoop-ozone/recon}} is clean.

Found by TLA+ model checking and code review of the Recon unhealthy container 
export job manager under HDDS-15926, on commit 
fb2d0310a9c6ea37c72cefb5cc1d5b4db35a7ab1. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. Related but not 
duplicates: HDDS-14913 (introduced {{ExportJobManager}}), HDDS-17208 (retention 
of SCM container ID export jobs, a different component). The attached patch is 
a proposal for review. Generated with Specula (Claude Opus 5.5).



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to