Skip to content

Commit abeec3f

Browse files
authored
Merge branch 'master' into double_booking_03
Signed-off-by: Diego Tavares <dtavares@imageworks.com>
2 parents 1a11fe7 + aea9a96 commit abeec3f

30 files changed

Lines changed: 283 additions & 75 deletions

File tree

VERSION.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.26
1+
1.27

cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupport.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ public interface DispatchSupport {
9393
*/
9494
static final AtomicLong clearedProcs = new AtomicLong(0);
9595

96+
/**
97+
* A lost proc whose release was deferred because the kill-before-release could not confirm the
98+
* frame was stopped and the host was not confirmed dead (likely a flapping host). The proc and
99+
* its RUNNING frame are left intact to avoid double-booking until the host is confirmed DOWN or
100+
* the frame completes naturally.
101+
*/
102+
static final AtomicLong deferredReleaseProcs = new AtomicLong(0);
103+
96104
/**
97105
* Long for counting dispatch errors
98106
*/
@@ -180,8 +188,10 @@ public interface DispatchSupport {
180188
* @param proc
181189
* @param reason
182190
* @param exitStatus
191+
* @return true if the proc was actually released (unbooked and its frame reset); false if the
192+
* release was deferred to avoid double-booking a possibly-still-rendering host
183193
*/
184-
void lostProc(VirtualProc proc, String reason, int exitStatus);
194+
boolean lostProc(VirtualProc proc, String reason, int exitStatus);
185195

186196
/**
187197
* Unbooks a proc with no message

cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -519,29 +519,65 @@ public void unbookProc(VirtualProc proc, String reason) {
519519

520520
@Override
521521
@Transactional(propagation = Propagation.NOT_SUPPORTED)
522-
public void lostProc(VirtualProc proc, String reason, int exitStatus) {
523-
long numCleared = clearedProcs.incrementAndGet();
524-
522+
public boolean lostProc(VirtualProc proc, String reason, int exitStatus) {
525523
/*
526524
* Kill the frame on RQD before releasing it back to a bookable state. Without this, a host
527525
* that is merely flapping (transient unreachable/DOWN report, GC pause, dropped report)
528526
* keeps rendering the frame while the dispatcher resets it to WAITING and re-books it
529-
* elsewhere, silently double-booking the frame onto two hosts. Killing first means a
530-
* reachable host stops the frame before it becomes re-bookable; a genuinely dead host times
531-
* out (caught below), and release is then safe because no live RQD remains.
532-
* EXIT_STATUS_FAILED_KILL is skipped because the caller (JobManagerSupport.kill) already
533-
* attempted this exact kill and it threw.
527+
* elsewhere, silently double-booking the frame onto two hosts.
534528
*/
535-
if (proc.frameId != null && exitStatus != Dispatcher.EXIT_STATUS_FAILED_KILL
536-
&& env.getProperty("dispatcher.kill_running_frame_before_release_enabled",
537-
Boolean.class, true)) {
529+
boolean killBeforeReleaseEnabled = env.getProperty(
530+
"dispatcher.kill_running_frame_before_release_enabled", Boolean.class, true);
531+
532+
/*
533+
* Whether the running frame is known to be stopped on RQD after this point.
534+
* EXIT_STATUS_FAILED_KILL means the caller (JobManagerSupport.kill) already attempted this
535+
* exact kill and it threw, so the frame is NOT known-stopped and we must not re-kill.
536+
*/
537+
boolean frameKilled;
538+
if (exitStatus == Dispatcher.EXIT_STATUS_FAILED_KILL) {
539+
frameKilled = false;
540+
} else if (proc.frameId != null && killBeforeReleaseEnabled) {
538541
try {
539542
rqdClient.killFrame(proc, "kill-before-release: " + reason);
543+
frameKilled = true;
540544
} catch (Exception e) {
541545
logger.info("kill-before-release failed for " + proc.getName() + ", " + e);
546+
frameKilled = false;
542547
}
548+
} else {
549+
// Feature disabled or no frame to kill: preserve legacy release behavior.
550+
frameKilled = true;
543551
}
544552

553+
/*
554+
* Flapping and genuinely-dead hosts are indistinguishable at kill time. If the kill could
555+
* not confirm the frame is stopped and the host is not confirmed dead, releasing the frame
556+
* now would re-book it onto a second host while this RQD keeps rendering. In that case we
557+
* DEFER the release: the proc row and RUNNING frame are left intact (preserving the
558+
* host<->frame link, which is otherwise lost once the proc is deleted) so the frame is
559+
* reclaimed later, once the host is confirmed DOWN (clearDownProcs) or the frame completes
560+
* naturally. A genuinely dead host (marked DOWN, or no longer Up) leaves no live RQD, so
561+
* release is safe. See design/frame_double_booking_v2.md.
562+
*/
563+
boolean deferReleaseEnabled = env.getProperty(
564+
"dispatcher.defer_release_on_failed_kill_enabled", Boolean.class, true);
565+
if (deferReleaseEnabled && !frameKilled && proc.frameId != null) {
566+
boolean hostConfirmedDead =
567+
exitStatus == Dispatcher.EXIT_STATUS_DOWN_HOST || !hostDao.isHostUp(proc);
568+
if (!hostConfirmedDead) {
569+
DispatchSupport.deferredReleaseProcs.incrementAndGet();
570+
logger.warn("Deferring release of lost proc " + proc.getName()
571+
+ ": kill-before-release could not confirm the frame stopped and the host "
572+
+ "is not confirmed dead. Leaving frame " + proc.frameId
573+
+ " RUNNING to avoid double-booking. reason=" + reason);
574+
return false;
575+
}
576+
}
577+
578+
// Count the clear only now that the proc is actually being released; deferrals above
579+
// return early and must not inflate this counter.
580+
long numCleared = clearedProcs.incrementAndGet();
545581
unbookProc(proc, "proc " + proc.getName() + " is #" + numCleared + " cleared: " + reason);
546582

547583
if (proc.frameId != null) {
@@ -575,6 +611,7 @@ public void lostProc(VirtualProc proc, String reason, int exitStatus) {
575611
} else {
576612
logger.info("Frame ID is NULL, not updating Frame state");
577613
}
614+
return true;
578615
}
579616

580617
@Override

cuebot/src/main/java/com/imageworks/spcue/servant/CueStatic.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public void getSystemStats(CueGetSystemStatsRequest request,
6969
.setKilledOffenderProcs(DispatchSupport.killedOffenderProcs.get())
7070
.setKilledOomProcs(DispatchSupport.killedOomProcs.get())
7171
.setClearedProcs(DispatchSupport.clearedProcs.get())
72+
.setDeferredReleaseProcs(DispatchSupport.deferredReleaseProcs.get())
7273
.setBookingRetries(DispatchSupport.bookingRetries.get())
7374
.setBookingErrors(DispatchSupport.bookingErrors.get())
7475
.setBookedProcs(DispatchSupport.bookedProcs.get())

cuebot/src/main/java/com/imageworks/spcue/service/MaintenanceManagerSupport.java

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -158,15 +158,17 @@ private void clearOrphanedProcs() {
158158
List<VirtualProc> procs = procDao.findOrphanedVirtualProcs(100);
159159
for (VirtualProc proc : procs) {
160160
try {
161-
dispatchSupport.lostProc(proc, "Removed by maintenance, orphaned",
162-
Dispatcher.EXIT_STATUS_FRAME_ORPHAN);
163-
164-
Sentry.configureScope(scope -> {
165-
scope.setExtra("frame_id", proc.getFrameId());
166-
scope.setExtra("host_id", proc.getHostId());
167-
scope.setExtra("name", proc.getName());
168-
Sentry.captureMessage("Manager cleaning orphan procs");
169-
});
161+
// Only report a cleanup when the proc was actually released; lostProc may defer the
162+
// release (leaving the proc intact) to avoid double-booking a flapping host.
163+
if (dispatchSupport.lostProc(proc, "Removed by maintenance, orphaned",
164+
Dispatcher.EXIT_STATUS_FRAME_ORPHAN)) {
165+
Sentry.withScope(scope -> {
166+
scope.setExtra("frame_id", proc.getFrameId());
167+
scope.setExtra("host_id", proc.getHostId());
168+
scope.setExtra("name", proc.getName());
169+
Sentry.captureMessage("Manager cleaning orphan procs");
170+
});
171+
}
170172
} catch (Exception e) {
171173
logger.info("failed to clear orphaned proc: " + proc.getName() + " " + e);
172174
}

cuebot/src/main/resources/opencue.properties

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ dispatcher.frame_kill_retry_limit=3
151151
# host simply times out and release proceeds. Default = true.
152152
dispatcher.kill_running_frame_before_release_enabled=true
153153

154+
# Companion to the setting above. Flapping and genuinely-dead hosts are indistinguishable at kill
155+
# time: when the kill-before-release cannot confirm the frame stopped (the RQD kill threw, or the
156+
# kill already failed upstream) and the host is not confirmed dead (not marked DOWN and still Up),
157+
# releasing the frame would re-book it onto a second host while the original RQD keeps rendering.
158+
# When true, the release is deferred in that case: the proc and its RUNNING frame are left intact
159+
# (preserving the host<->frame link) and reclaimed later once the host is confirmed DOWN or the
160+
# frame completes naturally. Set to false to restore the prior always-release behavior. Default =
161+
# true.
162+
dispatcher.defer_release_on_failed_kill_enabled=true
163+
154164
# When a reported running frame's resource_id maps to a proc that now owns a different frame, the
155165
# frame is demonstrably owned elsewhere: kill it immediately instead of waiting for the grace and
156166
# isOrphan windows. Set to false to disable this immediate kill; such mismatched frames are then

cuebot/src/test/java/com/imageworks/spcue/test/dispatcher/DispatchSupportServiceLostProcTests.java

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
import org.springframework.core.env.Environment;
2222
import org.springframework.test.util.ReflectionTestUtils;
2323

24+
import com.imageworks.spcue.FrameDetail;
2425
import com.imageworks.spcue.FrameInterface;
26+
import com.imageworks.spcue.HostInterface;
2527
import com.imageworks.spcue.VirtualProc;
2628
import com.imageworks.spcue.dao.FrameDao;
29+
import com.imageworks.spcue.dao.HostDao;
2730
import com.imageworks.spcue.dao.JobDao;
2831
import com.imageworks.spcue.dao.LayerDao;
2932
import com.imageworks.spcue.dao.ProcDao;
@@ -56,11 +59,14 @@ public class DispatchSupportServiceLostProcTests {
5659

5760
private static final String KILL_BEFORE_RELEASE_PROPERTY =
5861
"dispatcher.kill_running_frame_before_release_enabled";
62+
private static final String DEFER_RELEASE_PROPERTY =
63+
"dispatcher.defer_release_on_failed_kill_enabled";
5964

6065
private DispatchSupportService dispatchSupport;
6166
private RqdClient rqdClient;
6267
private ProcDao procDao;
6368
private FrameDao frameDao;
69+
private HostDao hostDao;
6470
private Environment env;
6571
private VirtualProc proc;
6672

@@ -70,19 +76,26 @@ public void setup() {
7076
rqdClient = mock(RqdClient.class);
7177
procDao = mock(ProcDao.class);
7278
frameDao = mock(FrameDao.class);
79+
hostDao = mock(HostDao.class);
7380
env = mock(Environment.class);
7481

7582
dispatchSupport.setRqdClient(rqdClient);
7683
dispatchSupport.setProcDao(procDao);
7784
dispatchSupport.setFrameDao(frameDao);
85+
dispatchSupport.setHostDao(hostDao);
7886
dispatchSupport.setShowDao(mock(ShowDao.class));
7987
dispatchSupport.setJobDao(mock(JobDao.class));
8088
dispatchSupport.setLayerDao(mock(LayerDao.class));
8189
ReflectionTestUtils.setField(dispatchSupport, "env", env);
8290

83-
// Kill-before-release enabled by default.
91+
// Kill-before-release and defer-release both enabled by default.
8492
when(env.getProperty(eq(KILL_BEFORE_RELEASE_PROPERTY), eq(Boolean.class), eq(true)))
8593
.thenReturn(true);
94+
when(env.getProperty(eq(DEFER_RELEASE_PROPERTY), eq(Boolean.class), eq(true)))
95+
.thenReturn(true);
96+
97+
// Default: the host still looks Up in the DB (the dangerous flapping case).
98+
when(hostDao.isHostUp(any(HostInterface.class))).thenReturn(true);
8699

87100
proc = new VirtualProc();
88101
proc.id = "00000000-0000-0000-0000-000000000001";
@@ -102,27 +115,111 @@ public void killsRqdBeforeReleasingProc() {
102115
InOrder inOrder = inOrder(rqdClient, procDao);
103116
inOrder.verify(rqdClient, times(1)).killFrame(eq(proc), anyString());
104117
inOrder.verify(procDao, times(1)).deleteVirtualProc(proc);
118+
verify(frameDao, times(1)).updateFrameStopped(any(FrameInterface.class),
119+
eq(FrameState.WAITING), anyInt());
105120
}
106121

107122
@Test
108123
public void skipsRqdKillForFailedKillExitStatus() {
109124
// The failed-kill path already attempted this exact kill, so lostProc must not re-issue it.
125+
// Since that kill demonstrably failed and the host still looks Up, the release is deferred
126+
// to avoid double-booking a possibly-still-rendering host.
127+
dispatchSupport.lostProc(proc, "failed kill", Dispatcher.EXIT_STATUS_FAILED_KILL);
128+
129+
verify(rqdClient, never()).killFrame(any(VirtualProc.class), anyString());
130+
verify(procDao, never()).deleteVirtualProc(any(VirtualProc.class));
131+
verify(frameDao, never()).updateFrameStopped(any(FrameInterface.class),
132+
any(FrameState.class), anyInt());
133+
}
134+
135+
@Test
136+
public void releasesForFailedKillWhenHostConfirmedDownByDb() {
137+
// FAILED_KILL but the host is no longer Up in the DB: no live RQD remains, release
138+
// proceeds.
139+
when(hostDao.isHostUp(any(HostInterface.class))).thenReturn(false);
140+
110141
dispatchSupport.lostProc(proc, "failed kill", Dispatcher.EXIT_STATUS_FAILED_KILL);
111142

112143
verify(rqdClient, never()).killFrame(any(VirtualProc.class), anyString());
113144
verify(procDao, times(1)).deleteVirtualProc(proc);
145+
verify(frameDao, times(1)).updateFrameStopped(any(FrameInterface.class),
146+
eq(FrameState.WAITING), anyInt());
114147
}
115148

116149
@Test
117-
public void releasesProcWhenRqdKillThrows() {
118-
// A genuinely dead/unreachable host makes the kill throw; release must still proceed.
150+
public void releasesProcWhenRqdKillThrowsForDownHost() {
151+
// A host reported DOWN is confirmed dead: the kill may throw, but release must still
152+
// proceed.
119153
doThrow(new RuntimeException("host unreachable")).when(rqdClient)
120154
.killFrame(any(VirtualProc.class), anyString());
121155

122156
dispatchSupport.lostProc(proc, "down host", Dispatcher.EXIT_STATUS_DOWN_HOST);
123157

124158
verify(rqdClient, times(1)).killFrame(eq(proc), anyString());
125159
verify(procDao, times(1)).deleteVirtualProc(proc);
160+
verify(frameDao, times(1)).updateFrameStopped(any(FrameInterface.class),
161+
eq(FrameState.WAITING), anyInt());
162+
}
163+
164+
@Test
165+
public void updatesFrameHostDownWhenFrameNotRunningForDownHost() {
166+
// Down host whose frame is no longer RUNNING but already DEAD: updateFrameStopped reports
167+
// nothing to stop, so the down-host fallback must reset it via updateFrameHostDown.
168+
when(frameDao.updateFrameStopped(any(FrameInterface.class), any(FrameState.class),
169+
anyInt())).thenReturn(false);
170+
FrameDetail deadFrame = new FrameDetail();
171+
deadFrame.state = FrameState.DEAD;
172+
when(frameDao.getFrameDetail(any(FrameInterface.class))).thenReturn(deadFrame);
173+
174+
dispatchSupport.lostProc(proc, "down host", Dispatcher.EXIT_STATUS_DOWN_HOST);
175+
176+
verify(procDao, times(1)).deleteVirtualProc(proc);
177+
verify(frameDao, times(1)).updateFrameHostDown(any(FrameInterface.class));
178+
}
179+
180+
@Test
181+
public void defersReleaseWhenKillThrowsAndHostStillUp() {
182+
// The flapping-host case: the kill throws and the host still looks Up. Releasing now would
183+
// re-book the frame onto a second host while this RQD keeps rendering, so we must defer.
184+
doThrow(new RuntimeException("transient unreachable")).when(rqdClient)
185+
.killFrame(any(VirtualProc.class), anyString());
186+
187+
dispatchSupport.lostProc(proc, "orphaned", Dispatcher.EXIT_STATUS_FRAME_ORPHAN);
188+
189+
verify(rqdClient, times(1)).killFrame(eq(proc), anyString());
190+
verify(procDao, never()).deleteVirtualProc(any(VirtualProc.class));
191+
verify(frameDao, never()).updateFrameStopped(any(FrameInterface.class),
192+
any(FrameState.class), anyInt());
193+
}
194+
195+
@Test
196+
public void releasesWhenKillThrowsAndHostNotUp() {
197+
// The kill throws but the host is no longer Up: confirmed dead, so release is safe.
198+
doThrow(new RuntimeException("host unreachable")).when(rqdClient)
199+
.killFrame(any(VirtualProc.class), anyString());
200+
when(hostDao.isHostUp(any(HostInterface.class))).thenReturn(false);
201+
202+
dispatchSupport.lostProc(proc, "orphaned", Dispatcher.EXIT_STATUS_FRAME_ORPHAN);
203+
204+
verify(rqdClient, times(1)).killFrame(eq(proc), anyString());
205+
verify(procDao, times(1)).deleteVirtualProc(proc);
206+
verify(frameDao, times(1)).updateFrameStopped(any(FrameInterface.class),
207+
eq(FrameState.WAITING), anyInt());
208+
}
209+
210+
@Test
211+
public void releasesWhenDeferDisabledByPropertyEvenIfKillThrows() {
212+
// With defer disabled, behavior falls back to the prior always-release semantics.
213+
when(env.getProperty(eq(DEFER_RELEASE_PROPERTY), eq(Boolean.class), eq(true)))
214+
.thenReturn(false);
215+
doThrow(new RuntimeException("transient unreachable")).when(rqdClient)
216+
.killFrame(any(VirtualProc.class), anyString());
217+
218+
dispatchSupport.lostProc(proc, "orphaned", Dispatcher.EXIT_STATUS_FRAME_ORPHAN);
219+
220+
verify(procDao, times(1)).deleteVirtualProc(proc);
221+
verify(frameDao, times(1)).updateFrameStopped(any(FrameInterface.class),
222+
eq(FrameState.WAITING), anyInt());
126223
}
127224

128225
@Test
@@ -134,5 +231,7 @@ public void skipsRqdKillWhenDisabledByProperty() {
134231

135232
verify(rqdClient, never()).killFrame(any(VirtualProc.class), anyString());
136233
verify(procDao, times(1)).deleteVirtualProc(proc);
234+
verify(frameDao, times(1)).updateFrameStopped(any(FrameInterface.class),
235+
eq(FrameState.WAITING), anyInt());
137236
}
138237
}

cuebot/src/test/resources/opencue.properties

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ dispatcher.frame_kill_retry_limit=3
6666

6767
# Kill the frame on RQD before releasing a lost/down/orphaned proc back to a bookable state.
6868
dispatcher.kill_running_frame_before_release_enabled=true
69+
# Defer releasing a lost proc when the kill could not confirm the frame stopped and the host is not
70+
# confirmed dead, to avoid double-booking a flapping host's frame.
71+
dispatcher.defer_release_on_failed_kill_enabled=true
6972
# Immediately kill a reported frame whose resource_id maps to a proc on a different frame.
7073
dispatcher.frame_verification_strict_fencing_enabled=true
7174
# Grace seconds shielding a reported frame whose resource_id maps to no proc (propagation window).

cueweb/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ NEXTAUTH_SECRET=canbeanything
7575
# CUEWEB_AUTHZ_ENABLED master switch; opt-in. Set to true to enable the
7676
# gate. Default: disabled (pure pass-through).
7777
# CUEWEB_ALLOWED_GROUPS groups allowed to use CueWeb at all
78-
# CUEWEB_ADMIN_GROUPS groups allowed to use the CueCommander admin pages
78+
# CUEWEB_ADMIN_GROUPS groups allowed on the whole CueCommander section, CueSubmit and Manage facilities
7979
# CUEWEB_GROUPS_CLAIM JWT/OIDC claim that holds the user's groups (default: groups)
8080
CUEWEB_AUTHZ_ENABLED=false
8181
CUEWEB_ALLOWED_GROUPS=

0 commit comments

Comments
 (0)