Skip to content

fix(plugins): serialize loadJob per definition id to prevent file-handle leak#83

Merged
fronzec merged 2 commits into
mainfrom
fix/plugin-engine-loadjob-atomicity-pr-b
Jul 4, 2026
Merged

fix(plugins): serialize loadJob per definition id to prevent file-handle leak#83
fronzec merged 2 commits into
mainfrom
fix/plugin-engine-loadjob-atomicity-pr-b

Conversation

@fronzec

@fronzec fronzec commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What

Makes DynamicJobLoaderService.loadJob atomic per definition id, closing a file-handle leak on concurrent loads (spine finding P1).

Why

loadJob had a check-then-act race with no per-definition mutual exclusion:

  1. It checks the already-loaded guard (getRegisteredJobNames().contains(jobName)).
  2. It creates a DynamicJobClassLoader — a URLClassLoader that opens the JAR file-handle.
  3. It stores the classloader via classLoaders.put(definitionId, classLoader).

Two concurrent POST /definitions/{id}/load for the same id both pass step 1 (neither is registered yet), both open the JAR, and the losing thread's put overwrites the winner's map entry without closing it. The overwritten classloader is never cleanup()-ed → leaked file-handle (the catch only cleans up on exception, not on the overwrite race).

How

  • Introduce ConcurrentHashMap<Long, ReentrantLock> loadLocks.
  • Extract the load body into a private doLoadJob and wrap it in a per-definition lock (lock() outside try, unlock() in finally — released on every path).
  • The guard, classloader creation, registry write, and classLoaders.put now execute atomically for a given id. The loser observes the winner's registration and is rejected with IllegalStateException before opening a second handle.

Since job_name is unique per definition (DB constraint) and id is the PK, locking by id is equivalent to locking by job name — no two ids collide on a name.

Testing

  • New deterministic concurrency test loadJob_concurrentSameId_serializesAndOpensSingleClassloader: parks the winner inside registerDynamicPlugin (holding the lock) and asserts the contender cannot enter the load body and never triggers a second registration. Fails without the fix, passes with it. Uses a real DynamicJobClassLoader (Mockito construction mocking is thread-confined).
  • DynamicJobLoaderServiceTest: 18/18 green. DynamicJobLoadingIntegrationTest: 13/13 green.

Known limitation (out of scope — tracked for PR-C)

This closes the load-vs-load leak only. unloadJob/reloadJob do not yet acquire the lock, so a load racing an unload/reload of the same id can still leave an orphaned classloader. That is the P2 slice (unload + transaction boundary) and lands in PR-C.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability when loading the same job definition concurrently.
    • Ensures per-definition serialization so only one load/register sequence runs at a time.
    • Prevents duplicate classloader/registration side effects for the same job definition.
  • Tests
    • Added/expanded concurrency tests to confirm the job is registered once and competing requests are properly blocked.
    • Added a guardrail test for missing job definitions to ensure no lock state is retained.

…dle leak

Two concurrent loadJob calls for the same definition id could both pass
the already-loaded guard, each open a DynamicJobClassLoader (JAR
file-handle), and the losing thread's classLoaders.put would overwrite
the winner's entry without closing it — leaking the winner's handle.

Wrap the load body (extracted into doLoadJob) in a per-definition
ReentrantLock so the guard, classloader creation, registry write, and
classLoaders.put execute atomically for a given id. The loser now
observes the winner's registration and is rejected before opening a
second handle.

Adds a deterministic concurrency test that reproduces the leak by
parking the winner inside registerDynamicPlugin and asserting the
contender cannot enter the load body.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 892484a7-ff8f-4a86-89ff-8d5976350bcf

📥 Commits

Reviewing files that changed from the base of the PR and between 2d7029e and 7b5c301.

📒 Files selected for processing (2)
  • fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java
  • fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java

📝 Walkthrough

Walkthrough

DynamicJobLoaderService now serializes same-definition loadJob calls with per-id ReentrantLocks. The load path is split so the lock is held only around the actual load work, and tests cover missing IDs plus concurrent same-id loads.

Changes

Concurrent job load serialization

Layer / File(s) Summary
Per-definition lock implementation
fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java
Adds a loadLocks map of ReentrantLock instances keyed by definition id and refactors loadJob to verify the definition, lock per id, call doLoadJob, and unlock in finally.
Lock retention and concurrency tests
fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java
Adds concurrency imports, a reflection helper for the private lock map, a missing-id test that checks the map stays empty, and a parallel-load test that asserts only one registration occurs.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the core fix: serializing loadJob per definition id to prevent concurrent file-handle leaks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/plugin-engine-loadjob-atomicity-pr-b

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

fronzec commented Jul 2, 2026

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@fronzec
fronzec marked this pull request as ready for review July 2, 2026 07:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java (1)

22-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the wildcard concurrency import with explicit imports.

Proposed fix
-import java.util.concurrent.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;

As per coding guidelines, fr-batch-service/**/*.java: “Avoid wildcard imports (import com.example.*) and prefer explicit imports”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java`
around lines 22 - 23, Replace the wildcard java.util.concurrent import in
DynamicJobLoaderServiceTest with explicit imports for only the concurrency types
actually used, keeping AtomicInteger as a direct import and removing the blanket
import to match the no-wildcard-import guideline.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java`:
- Around line 54-63: The per-definition lock map in DynamicJobLoaderService is
retaining ReentrantLock entries for IDs that do not exist because the lock is
created before the repository check. Change the flow around loadJob(Long) so
existence is validated via findById before calling computeIfAbsent on loadLocks,
and keep the second existence check inside doLoadJob under the lock for
correctness. Preserve the existing atomic load behavior while ensuring
nonexistent definition IDs do not leave permanent entries in loadLocks.

In
`@fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java`:
- Around line 526-538: The concurrent load test in DynamicJobLoaderServiceTest
leaves the real classloader open after a successful load, so add cleanup by
calling the matching unload path after the assertions to release the temp JAR
handle deterministically. Use the existing DynamicJobLoaderService/unloadJob
flow (with the loaded definition id from the test setup) so the classloader
tracked by the load is closed before the test finishes.

---

Nitpick comments:
In
`@fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java`:
- Around line 22-23: Replace the wildcard java.util.concurrent import in
DynamicJobLoaderServiceTest with explicit imports for only the concurrency types
actually used, keeping AtomicInteger as a direct import and removing the blanket
import to match the no-wildcard-import guideline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a552ff94-040f-4da5-a876-a22603569bc1

📥 Commits

Reviewing files that changed from the base of the PR and between dcad8e8 and 2d7029e.

📒 Files selected for processing (2)
  • fr-batch-service/src/main/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderService.java
  • fr-batch-service/src/test/java/com/fronzec/frbatchservice/batchjobs/plugins/loader/DynamicJobLoaderServiceTest.java

Gate per-definition lock allocation on findById existence so that a load
request for an unknown id (e.g. POST /definitions/{id}/load with a bogus
id) no longer stores a ReentrantLock in loadLocks forever. The
authoritative existence check still runs inside doLoadJob under the lock.

Also clean up the concurrent-load test: unload after the assertions to
release the real classloader/temp-JAR handle, release the register latch
in finally so shutdown cannot hang, and replace the wildcard
java.util.concurrent import with explicit imports.
@fronzec
fronzec merged commit 8c103c7 into main Jul 4, 2026
8 checks passed
@fronzec
fronzec deleted the fix/plugin-engine-loadjob-atomicity-pr-b branch July 4, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant