Skip to content

Commit 8be7238

Browse files
authored
fix: New resources now unblock waiting jobs immediately (#1004)
When a new resource is added via createResourceWithLabel() or addResource(), waiting jobs now automatically pick it up instead of remaining blocked. Changes: - Invalidate cachedCandidates and process waiting pipeline contexts when adding new resources (inside synchronized block for atomicity) - Call scheduleQueueMaintenance() to notify freestyle jobs - Add refreshQueue() public method for users who modify labels on existing resources (label changes don't auto-trigger re-evaluation) - Add documentation explaining dynamic resource behavior and limitations Fixes #892 See also: JENKINS-46744
1 parent 7e3189a commit 8be7238

5 files changed

Lines changed: 265 additions & 0 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,26 @@ More examples are [here](src/doc/examples/readme.md).
274274

275275
----
276276

277+
## Dynamic resource behavior
278+
279+
When new resources are added to the system, waiting jobs can automatically pick them up:
280+
281+
- **Pipeline jobs** waiting in the lock step queue are re-evaluated immediately
282+
- **Freestyle jobs** waiting in the Jenkins build queue are re-evaluated via queue maintenance
283+
284+
This allows you to dynamically add resources to your resource pool without requiring waiting jobs to be restarted.
285+
286+
### Example
287+
288+
If a job is waiting for a resource with label `printer` and all existing printer resources are locked, adding a new resource with the label `printer` will allow the waiting job to acquire it immediately.
289+
290+
### Limitations
291+
292+
- **Modifying labels on existing resources does NOT trigger re-evaluation.** Only adding new resources triggers waiting jobs to re-evaluate. If you change labels on an existing resource, you can manually call `LockableResourcesManager.get().refreshQueue()` via Script Console to notify waiting jobs.
293+
- **Removing resources** does not affect waiting jobs (they continue waiting for other resources with matching criteria).
294+
295+
----
296+
277297
## Node mirroring
278298

279299
Lockable resources plugin allow to mirror nodes (agents) into lockable resources. This eliminate effort by re-creating resources on every node change.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Dynamic Resource Pool Expansion
2+
3+
This example demonstrates how waiting jobs can automatically acquire newly added resources.
4+
5+
## Use Case
6+
7+
You have a pool of resources (e.g., test devices) labeled `test-device`. All devices are currently in use by running jobs. A new job starts and waits for a `test-device`. When you add a new device to the pool, the waiting job should automatically acquire it without needing to be restarted.
8+
9+
## Pipeline Example
10+
11+
### Job waiting for a resource
12+
13+
```groovy
14+
pipeline {
15+
agent any
16+
stages {
17+
stage('Acquire Device') {
18+
steps {
19+
lock(label: 'test-device', quantity: 1, variable: 'DEVICE') {
20+
echo "Acquired device: ${env.DEVICE}"
21+
// Use the device
22+
sh 'run-tests.sh'
23+
}
24+
}
25+
}
26+
}
27+
}
28+
```
29+
30+
### Adding a new resource via pipeline step
31+
32+
While the job is waiting, you can add a new resource via a management job or the Script Console using the `updateLock` step:
33+
34+
```groovy
35+
// In a pipeline job
36+
updateLock(resource: 'new-test-device-5', addLabels: 'test-device')
37+
```
38+
39+
Or via Script Console:
40+
41+
```groovy
42+
import org.jenkins.plugins.lockableresources.LockableResourcesManager
43+
44+
def manager = LockableResourcesManager.get()
45+
manager.createResourceWithLabel('new-test-device-5', 'test-device')
46+
```
47+
48+
The waiting job will automatically acquire `new-test-device-5` once it is added.
49+
50+
## Freestyle Job Example
51+
52+
For freestyle jobs configured with **Required Resources** (label: `test-device`), the same behavior applies. When a new resource with the matching label is added, the Jenkins queue is notified and the waiting freestyle job will be dispatched.
53+
54+
## Limitations
55+
56+
> **Important:** Modifying labels on existing resources does NOT trigger re-evaluation.
57+
58+
Only **adding new resources** triggers waiting jobs to re-evaluate their resource requirements. If you:
59+
- Change labels on an existing resource (e.g., add `test-device` label to an existing resource)
60+
- The waiting jobs will **not** be notified
61+
62+
To work around this limitation, you can manually trigger queue refresh via Script Console:
63+
64+
```groovy
65+
import org.jenkins.plugins.lockableresources.LockableResourcesManager
66+
67+
LockableResourcesManager.get().refreshQueue()
68+
```
69+
70+
This will invalidate the cached candidates and notify both pipeline and freestyle jobs to re-evaluate available resources.
71+
72+
## Related
73+
74+
- [JENKINS-46744](https://issues.jenkins.io/browse/JENKINS-46744) - Original issue requesting this behavior
75+
- [GitHub #892](https://github.com/jenkinsci/lockable-resources-plugin/issues/892) - Implementation tracking issue

src/doc/examples/readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ If you have a question, please open a [GitHub issue](https://github.com/jenkinsc
99
- [Locking multiple stages in declarative pipeline](locking-multiple-stages-in-declarative-pipeline.md)
1010
- [Locking a random free resource](locking-random-free-resource.md)
1111
- [Scripted vs declarative pipeline](scripted-vs-declarative-pipeline.md)
12+
- [Dynamic resource pool expansion](dynamic-resource-pool-expansion.md)

src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -972,10 +972,19 @@ public boolean addResource(@Nullable final LockableResource resource, final bool
972972
}
973973
this.resources.add(resource);
974974
LOGGER.fine("Resource added : " + resource);
975+
976+
// Invalidate cache and process waiting pipeline jobs while still holding the lock
977+
cachedCandidates.invalidateAll();
978+
while (proceedNextContext()) {
979+
// process as many contexts as possible
980+
}
981+
975982
if (doSave) {
976983
this.save();
977984
}
978985
}
986+
// Notify Jenkins queue for freestyle jobs (must be outside synchronized block)
987+
scheduleQueueMaintenance();
979988
return true;
980989
}
981990

@@ -1158,6 +1167,7 @@ public void removeResources(List<LockableResource> toBeRemoved) {
11581167
synchronized (syncResources) {
11591168
this.resources.removeAll(toBeRemoved);
11601169
}
1170+
scheduleQueueMaintenance();
11611171
}
11621172

11631173
// ---------------------------------------------------------------------------
@@ -1467,6 +1477,35 @@ public static void scheduleQueueMaintenance() {
14671477
}
14681478
}
14691479

1480+
// ---------------------------------------------------------------------------
1481+
/**
1482+
* Refresh the queue to allow waiting jobs to re-evaluate available resources.
1483+
* <p>
1484+
* This method should be called after modifying labels on existing resources,
1485+
* as label changes do not automatically trigger queue re-evaluation.
1486+
* <p>
1487+
* It performs the following actions:
1488+
* <ol>
1489+
* <li>Invalidates the cached resource candidates</li>
1490+
* <li>Processes waiting pipeline job contexts</li>
1491+
* <li>Triggers Jenkins queue maintenance for freestyle jobs</li>
1492+
* </ol>
1493+
*/
1494+
public void refreshQueue() {
1495+
// Invalidate cached candidates so waiting jobs re-evaluate with current labels
1496+
cachedCandidates.invalidateAll();
1497+
1498+
// Process waiting pipeline jobs
1499+
synchronized (syncResources) {
1500+
while (proceedNextContext()) {
1501+
// process as many contexts as possible
1502+
}
1503+
}
1504+
1505+
// Notify Jenkins queue for freestyle jobs
1506+
scheduleQueueMaintenance();
1507+
}
1508+
14701509
// ---------------------------------------------------------------------------
14711510
private AtomicBoolean getSavePending() {
14721511
AtomicBoolean sp = savePending;
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* The MIT License
3+
*
4+
* See the "LICENSE.txt" file for full copyright and license information.
5+
*/
6+
package org.jenkins.plugins.lockableresources;
7+
8+
import static org.junit.jupiter.api.Assertions.assertTrue;
9+
10+
import hudson.model.FreeStyleBuild;
11+
import hudson.model.FreeStyleProject;
12+
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
13+
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
14+
import org.jenkinsci.plugins.workflow.job.WorkflowRun;
15+
import org.jenkinsci.plugins.workflow.test.steps.SemaphoreStep;
16+
import org.junit.jupiter.api.Test;
17+
import org.jvnet.hudson.test.Issue;
18+
import org.jvnet.hudson.test.JenkinsRule;
19+
import org.jvnet.hudson.test.junit.jupiter.WithJenkins;
20+
21+
/**
22+
* Test that jobs waiting for resources can pick up newly added resources.
23+
* See JENKINS-46744 / issue #892.
24+
*
25+
* <p>Note: The lock step validates that at least one resource with the given
26+
* label exists. If no resource has the label, it fails. Therefore we need
27+
* an existing resource (locked by job1) before job2 can wait for a resource
28+
* with that label.</p>
29+
*/
30+
@WithJenkins
31+
class NewResourceUnblocksWaitingJobTest extends LockStepTestBase {
32+
33+
/**
34+
* Test that a pipeline job waiting for a resource by label can acquire
35+
* a newly added resource that has the matching label. (JENKINS-46744)
36+
*
37+
* Scenario:
38+
* 1. Job1 locks r1 (the only resource with label "test-label")
39+
* 2. Job2 tries to lock a resource with "test-label" - it waits
40+
* 3. A new resource "r2" with label "test-label" is added
41+
* 4. Job2 should acquire "r2" without waiting for Job1 to finish
42+
*/
43+
@Issue("JENKINS-46744")
44+
@Test
45+
void newResourceWithLabelUnblocksWaitingPipelineJob(JenkinsRule j) throws Exception {
46+
// Create resource r1 that will be locked by job1
47+
LockableResourcesManager lrm = LockableResourcesManager.get();
48+
assertTrue(lrm.createResourceWithLabel("r1", "test-label"));
49+
50+
// Job1: locks r1 and holds it via semaphore
51+
WorkflowJob job1 = j.jenkins.createProject(WorkflowJob.class, "job1");
52+
job1.setDefinition(new CpsFlowDefinition("""
53+
lock(label: 'test-label', quantity: 1, variable: 'LOCKED') {
54+
echo("Job1 locked: ${env.LOCKED}")
55+
semaphore('hold-lock')
56+
}
57+
""", true));
58+
59+
// Job2: tries to lock a resource with test-label
60+
WorkflowJob job2 = j.jenkins.createProject(WorkflowJob.class, "job2");
61+
job2.setDefinition(new CpsFlowDefinition("""
62+
timeout(time: 60, unit: 'SECONDS') {
63+
lock(label: 'test-label', quantity: 1, variable: 'LOCKED') {
64+
echo("Job2 locked: ${env.LOCKED}")
65+
}
66+
}
67+
""", true));
68+
69+
// Start job1 - it will lock r1 and wait at semaphore
70+
WorkflowRun run1 = job1.scheduleBuild2(0).waitForStart();
71+
j.waitForMessage("Job1 locked: r1", run1);
72+
73+
// Start job2 - it should wait because r1 (the only resource) is locked
74+
WorkflowRun run2 = job2.scheduleBuild2(0).waitForStart();
75+
j.waitForMessage("is not free, waiting for execution", run2);
76+
77+
// Now add a new resource with the same label
78+
// This should automatically trigger proceedNextContext() (JENKINS-46744)
79+
assertTrue(lrm.createResourceWithLabel("r2", "test-label"));
80+
81+
// The job should now proceed and acquire r2
82+
j.waitForMessage("Job2 locked: r2", run2);
83+
j.assertBuildStatusSuccess(j.waitForCompletion(run2));
84+
85+
// Now let job1 finish
86+
SemaphoreStep.success("hold-lock/1", null);
87+
j.assertBuildStatusSuccess(j.waitForCompletion(run1));
88+
}
89+
90+
/**
91+
* Test that a freestyle job waiting for a resource by label can acquire
92+
* a newly added resource. (JENKINS-46744)
93+
*/
94+
@Issue("JENKINS-46744")
95+
@Test
96+
void newResourceUnblocksWaitingFreestyleJob(JenkinsRule j) throws Exception {
97+
LockableResourcesManager lrm = LockableResourcesManager.get();
98+
// Create resource r1 that will be locked by job1
99+
assertTrue(lrm.createResourceWithLabel("r1", "test-label"));
100+
101+
// Job1: locks r1 and sleeps (holds the lock)
102+
FreeStyleProject job1 = j.createFreeStyleProject("freestyle-job1");
103+
job1.addProperty(new RequiredResourcesProperty(null, null, "1", "test-label", null));
104+
job1.getBuildersList().add(new org.jvnet.hudson.test.SleepBuilder(10000));
105+
106+
// Job2: wants test-label
107+
FreeStyleProject job2 = j.createFreeStyleProject("freestyle-job2");
108+
job2.addProperty(new RequiredResourcesProperty(null, null, "1", "test-label", null));
109+
110+
// Start job1 - it will lock r1 and sleep
111+
FreeStyleBuild build1 = job1.scheduleBuild2(0).waitForStart();
112+
j.waitForMessage("acquired lock on [r1]", build1);
113+
114+
// Start job2 - it should wait in queue because r1 is locked
115+
var future2 = job2.scheduleBuild2(0);
116+
Thread.sleep(1000); // Give time for job2 to enter queue
117+
118+
// Now add a new resource with the same label
119+
// This should trigger scheduleQueueMaintenance() (JENKINS-46744)
120+
assertTrue(lrm.createResourceWithLabel("r2", "test-label"));
121+
122+
// Job2 should now get dispatched and acquire r2
123+
FreeStyleBuild build2 = future2.waitForStart();
124+
j.waitForMessage("acquired lock on [r2]", build2);
125+
j.waitForCompletion(build2);
126+
127+
// Job1 will finish on its own after sleeping
128+
j.waitForCompletion(build1);
129+
}
130+
}

0 commit comments

Comments
 (0)