screwdriver-queue-service 5.0.3 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/default.yaml +2 -2
- package/docs/ARCHITECTURE_REDESIGN.md +214 -0
- package/docs/QS-REDIS-ATOMIC-REDESIGN.png +0 -0
- package/package.json +2 -1
- package/plugins/worker/lib/BlockedBy.js +144 -330
- package/plugins/worker/lib/LuaScriptLoader.js +232 -0
- package/plugins/worker/lib/jobs.js +74 -26
- package/plugins/worker/lib/lua/checkTimeout.lua +166 -0
- package/plugins/worker/lib/lua/lib/CollapseDecider.lua +155 -0
- package/plugins/worker/lib/lua/lib/DependencyResolver.lua +109 -0
- package/plugins/worker/lib/lua/lib/StateValidator.lua +179 -0
- package/plugins/worker/lib/lua/lib/TimeoutDecider.lua +161 -0
- package/plugins/worker/lib/lua/startBuild.lua +217 -0
- package/plugins/worker/lib/lua/stopBuild.lua +135 -0
- package/plugins/worker/lib/timeout.js +123 -68
- package/plugins/worker/worker.js +10 -10
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
This module provides functions to resolve build dependencies
|
|
3
|
+
and determine if a build is blocked by other builds.
|
|
4
|
+
]]
|
|
5
|
+
|
|
6
|
+
local DependencyResolver = {}
|
|
7
|
+
|
|
8
|
+
--[[
|
|
9
|
+
Parse blockedBy value (could be array, single value, or nil)
|
|
10
|
+
@param blockedBy - Array of job IDs or single job ID
|
|
11
|
+
@return {blocked, dependencies}
|
|
12
|
+
]]
|
|
13
|
+
function DependencyResolver.parseBlockedBy(blockedBy)
|
|
14
|
+
-- Check for nil or cjson.null (JavaScript null becomes cjson.null)
|
|
15
|
+
if not blockedBy or blockedBy == cjson.null then
|
|
16
|
+
return {blocked = false, dependencies = {}}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
-- Handle array
|
|
20
|
+
if type(blockedBy) == "table" then
|
|
21
|
+
if #blockedBy == 0 then
|
|
22
|
+
return {blocked = false, dependencies = {}}
|
|
23
|
+
end
|
|
24
|
+
return {blocked = true, dependencies = blockedBy}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
-- Handle single value
|
|
28
|
+
return {blocked = true, dependencies = {blockedBy}}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
--[[
|
|
32
|
+
Check if build is blocked by running dependencies
|
|
33
|
+
@param dependencies - Array of job IDs that block this build
|
|
34
|
+
@param runningBuilds - Array of currently running build IDs
|
|
35
|
+
@return {blocked, blockedBy, reason}
|
|
36
|
+
]]
|
|
37
|
+
function DependencyResolver.isBlockedByDependencies(dependencies, runningBuilds)
|
|
38
|
+
local blockedByBuilds = {}
|
|
39
|
+
|
|
40
|
+
-- Check if any dependency is in running builds
|
|
41
|
+
for _, dep in ipairs(dependencies) do
|
|
42
|
+
for _, running in ipairs(runningBuilds) do
|
|
43
|
+
-- Convert both to strings for comparison
|
|
44
|
+
if tostring(dep) == tostring(running) then
|
|
45
|
+
table.insert(blockedByBuilds, dep)
|
|
46
|
+
break
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
local isBlocked = #blockedByBuilds > 0
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
blocked = isBlocked,
|
|
55
|
+
blockedBy = blockedByBuilds,
|
|
56
|
+
reason = isBlocked and "BLOCKED_BY_DEPENDENCIES" or "NOT_BLOCKED"
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
--[[
|
|
61
|
+
Check if build is blocked by same job (another build of same job running)
|
|
62
|
+
@param jobId - Current job ID
|
|
63
|
+
@param runningBuildId - Currently running build ID for this job (or nil)
|
|
64
|
+
@param buildId - Current build ID
|
|
65
|
+
@param blockedBySelf - Whether blocking by same job is enabled
|
|
66
|
+
@return {blocked, reason}
|
|
67
|
+
]]
|
|
68
|
+
function DependencyResolver.isBlockedBySameJob(jobId, runningBuildId, buildId, blockedBySelf)
|
|
69
|
+
if not blockedBySelf then
|
|
70
|
+
return {blocked = false, reason = "BLOCKED_BY_SELF_DISABLED"}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
-- Check for nil or cjson.null (JavaScript null becomes cjson.null)
|
|
74
|
+
if not runningBuildId or runningBuildId == cjson.null then
|
|
75
|
+
return {blocked = false, reason = "NO_RUNNING_BUILD"}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
-- Convert to numbers for comparison
|
|
79
|
+
local runningId = tonumber(runningBuildId)
|
|
80
|
+
local currentId = tonumber(buildId)
|
|
81
|
+
|
|
82
|
+
if runningId == currentId then
|
|
83
|
+
return {blocked = false, reason = "SAME_BUILD_RUNNING"}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
blocked = true,
|
|
88
|
+
reason = "BLOCKED_BY_SAME_JOB",
|
|
89
|
+
runningBuildId = runningId
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
--[[
|
|
94
|
+
Build blocked message for logging/status
|
|
95
|
+
@param blockedBy - Array of blocking build IDs
|
|
96
|
+
@param reason - Reason for blocking
|
|
97
|
+
@return string message
|
|
98
|
+
]]
|
|
99
|
+
function DependencyResolver.buildBlockedMessage(blockedBy, reason)
|
|
100
|
+
-- Check for nil, cjson.null, or empty array
|
|
101
|
+
if not blockedBy or blockedBy == cjson.null or #blockedBy == 0 then
|
|
102
|
+
return "Build is blocked: " .. reason
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
local buildIds = table.concat(blockedBy, ", ")
|
|
106
|
+
return "Build is blocked by: " .. buildIds .. " (" .. reason .. ")"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
return DependencyResolver
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
StateValidator
|
|
3
|
+
]]
|
|
4
|
+
|
|
5
|
+
local StateValidator = {}
|
|
6
|
+
|
|
7
|
+
-- Define state constants
|
|
8
|
+
StateValidator.STATES = {
|
|
9
|
+
QUEUED = "QUEUED",
|
|
10
|
+
BLOCKED = "BLOCKED",
|
|
11
|
+
READY = "READY",
|
|
12
|
+
RUNNING = "RUNNING",
|
|
13
|
+
SUCCESS = "SUCCESS",
|
|
14
|
+
FAILURE = "FAILURE",
|
|
15
|
+
ABORTED = "ABORTED",
|
|
16
|
+
COLLAPSED = "COLLAPSED"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
-- Define action constants
|
|
20
|
+
StateValidator.ACTIONS = {
|
|
21
|
+
START = "START",
|
|
22
|
+
BLOCK = "BLOCK",
|
|
23
|
+
COLLAPSE = "COLLAPSE",
|
|
24
|
+
ABORT = "ABORT",
|
|
25
|
+
SKIP = "SKIP"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
--[[
|
|
29
|
+
Compute action based on current conditions
|
|
30
|
+
Priority: ABORT > COLLAPSE > BLOCK > START
|
|
31
|
+
|
|
32
|
+
@param isAborted - Whether build was aborted
|
|
33
|
+
@param isBlocked - Whether build is blocked by dependencies
|
|
34
|
+
@param shouldCollapse - Whether build should be collapsed
|
|
35
|
+
@param isRetry - Whether this is a retry attempt
|
|
36
|
+
@return {action, reason, priority, nextState}
|
|
37
|
+
]]
|
|
38
|
+
function StateValidator.computeAction(isAborted, isBlocked, shouldCollapse, isRetry)
|
|
39
|
+
-- Priority 1: ABORT (highest priority)
|
|
40
|
+
if isAborted then
|
|
41
|
+
return {
|
|
42
|
+
action = StateValidator.ACTIONS.ABORT,
|
|
43
|
+
reason = "BUILD_ABORTED",
|
|
44
|
+
priority = 1,
|
|
45
|
+
nextState = StateValidator.STATES.ABORTED
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
-- Priority 2: COLLAPSE
|
|
50
|
+
if shouldCollapse then
|
|
51
|
+
return {
|
|
52
|
+
action = StateValidator.ACTIONS.COLLAPSE,
|
|
53
|
+
reason = "NEWER_BUILD_EXISTS",
|
|
54
|
+
priority = 2,
|
|
55
|
+
nextState = StateValidator.STATES.COLLAPSED
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Priority 3: BLOCK
|
|
60
|
+
if isBlocked then
|
|
61
|
+
return {
|
|
62
|
+
action = StateValidator.ACTIONS.BLOCK,
|
|
63
|
+
reason = "BLOCKED_BY_DEPENDENCIES",
|
|
64
|
+
priority = 3,
|
|
65
|
+
nextState = StateValidator.STATES.BLOCKED
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
-- Priority 4: START (lowest priority, default action)
|
|
70
|
+
return {
|
|
71
|
+
action = StateValidator.ACTIONS.START,
|
|
72
|
+
reason = "READY",
|
|
73
|
+
priority = 4,
|
|
74
|
+
nextState = StateValidator.STATES.RUNNING
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
--[[
|
|
79
|
+
Check if a state is terminal (no further transitions)
|
|
80
|
+
@param state - State to check
|
|
81
|
+
@return boolean
|
|
82
|
+
]]
|
|
83
|
+
function StateValidator.isTerminalState(state)
|
|
84
|
+
return state == StateValidator.STATES.SUCCESS or
|
|
85
|
+
state == StateValidator.STATES.FAILURE or
|
|
86
|
+
state == StateValidator.STATES.ABORTED or
|
|
87
|
+
state == StateValidator.STATES.COLLAPSED
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
--[[
|
|
91
|
+
Validate if a state transition is allowed
|
|
92
|
+
@param currentState - Current state
|
|
93
|
+
@param nextState - Desired next state
|
|
94
|
+
@return {valid, reason}
|
|
95
|
+
]]
|
|
96
|
+
function StateValidator.isValidTransition(currentState, nextState)
|
|
97
|
+
-- Terminal states cannot transition
|
|
98
|
+
if StateValidator.isTerminalState(currentState) then
|
|
99
|
+
return {
|
|
100
|
+
valid = false,
|
|
101
|
+
reason = "CURRENT_STATE_IS_TERMINAL"
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
-- Define valid transitions
|
|
106
|
+
local validTransitions = {
|
|
107
|
+
[StateValidator.STATES.QUEUED] = {
|
|
108
|
+
StateValidator.STATES.BLOCKED,
|
|
109
|
+
StateValidator.STATES.READY,
|
|
110
|
+
StateValidator.STATES.RUNNING,
|
|
111
|
+
StateValidator.STATES.ABORTED,
|
|
112
|
+
StateValidator.STATES.COLLAPSED
|
|
113
|
+
},
|
|
114
|
+
[StateValidator.STATES.BLOCKED] = {
|
|
115
|
+
StateValidator.STATES.READY,
|
|
116
|
+
StateValidator.STATES.RUNNING,
|
|
117
|
+
StateValidator.STATES.ABORTED,
|
|
118
|
+
StateValidator.STATES.COLLAPSED
|
|
119
|
+
},
|
|
120
|
+
[StateValidator.STATES.READY] = {
|
|
121
|
+
StateValidator.STATES.RUNNING,
|
|
122
|
+
StateValidator.STATES.ABORTED
|
|
123
|
+
},
|
|
124
|
+
[StateValidator.STATES.RUNNING] = {
|
|
125
|
+
StateValidator.STATES.SUCCESS,
|
|
126
|
+
StateValidator.STATES.FAILURE,
|
|
127
|
+
StateValidator.STATES.ABORTED
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
local allowedStates = validTransitions[currentState]
|
|
132
|
+
|
|
133
|
+
if not allowedStates then
|
|
134
|
+
return {
|
|
135
|
+
valid = false,
|
|
136
|
+
reason = "NO_TRANSITIONS_FROM_STATE"
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
-- Check if nextState is in allowed list
|
|
141
|
+
for _, allowedState in ipairs(allowedStates) do
|
|
142
|
+
if nextState == allowedState then
|
|
143
|
+
return {
|
|
144
|
+
valid = true,
|
|
145
|
+
reason = "VALID_TRANSITION"
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
valid = false,
|
|
152
|
+
reason = "INVALID_TRANSITION"
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
--[[
|
|
157
|
+
Get the current state based on conditions
|
|
158
|
+
@param hasRunningKey - Whether running key exists
|
|
159
|
+
@param hasWaitingEntry - Whether build is in waiting queue
|
|
160
|
+
@param isAborted - Whether build was aborted
|
|
161
|
+
@return state
|
|
162
|
+
]]
|
|
163
|
+
function StateValidator.inferState(hasRunningKey, hasWaitingEntry, isAborted)
|
|
164
|
+
if isAborted then
|
|
165
|
+
return StateValidator.STATES.ABORTED
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
if hasRunningKey then
|
|
169
|
+
return StateValidator.STATES.RUNNING
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if hasWaitingEntry then
|
|
173
|
+
return StateValidator.STATES.BLOCKED
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
return StateValidator.STATES.QUEUED
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
return StateValidator
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
This module determines whether a build has timed out and what action to take.
|
|
3
|
+
]]
|
|
4
|
+
|
|
5
|
+
local TimeoutDecider = {}
|
|
6
|
+
|
|
7
|
+
--[[
|
|
8
|
+
Parse timeout value, handling NaN cases
|
|
9
|
+
@param timeoutValue - Timeout value (string or number)
|
|
10
|
+
@param defaultTimeout - Default timeout in minutes (default: 90)
|
|
11
|
+
@return number (parsed timeout or default)
|
|
12
|
+
]]
|
|
13
|
+
function TimeoutDecider.parseTimeout(timeoutValue, defaultTimeout)
|
|
14
|
+
-- Handle cjson.null for defaultTimeout (JavaScript null becomes cjson.null)
|
|
15
|
+
if not defaultTimeout or defaultTimeout == cjson.null then
|
|
16
|
+
defaultTimeout = 90
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
-- Handle cjson.null for timeoutValue
|
|
20
|
+
if not timeoutValue or timeoutValue == cjson.null then
|
|
21
|
+
return defaultTimeout
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
local parsed = tonumber(timeoutValue)
|
|
25
|
+
|
|
26
|
+
if not parsed or parsed ~= parsed then -- NaN check
|
|
27
|
+
return defaultTimeout
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
return parsed
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
--[[
|
|
34
|
+
Check if a build has timed out
|
|
35
|
+
@param startTime - Build start timestamp (milliseconds)
|
|
36
|
+
@param currentTime - Current timestamp (milliseconds)
|
|
37
|
+
@param timeoutMinutes - Timeout duration in minutes
|
|
38
|
+
@param bufferMinutes - Buffer time in minutes (default: 1)
|
|
39
|
+
@return {hasTimedOut, elapsedMinutes, effectiveTimeout, reason}
|
|
40
|
+
]]
|
|
41
|
+
function TimeoutDecider.hasTimedOut(startTime, currentTime, timeoutMinutes, bufferMinutes)
|
|
42
|
+
-- Handle cjson.null for bufferMinutes (JavaScript null becomes cjson.null)
|
|
43
|
+
if not bufferMinutes or bufferMinutes == cjson.null then
|
|
44
|
+
bufferMinutes = 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
-- Handle cjson.null for startTime
|
|
48
|
+
if not startTime or startTime == cjson.null then
|
|
49
|
+
return {
|
|
50
|
+
hasTimedOut = false,
|
|
51
|
+
reason = "NO_START_TIME",
|
|
52
|
+
elapsedMinutes = 0,
|
|
53
|
+
effectiveTimeout = timeoutMinutes + bufferMinutes
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
local elapsedMs = currentTime - startTime
|
|
58
|
+
local elapsedMinutes = math.floor(elapsedMs / 60000 + 0.5) -- Round to nearest minute
|
|
59
|
+
local effectiveTimeout = timeoutMinutes + bufferMinutes
|
|
60
|
+
|
|
61
|
+
local timedOut = elapsedMinutes > effectiveTimeout
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
hasTimedOut = timedOut,
|
|
65
|
+
elapsedMinutes = elapsedMinutes,
|
|
66
|
+
effectiveTimeout = effectiveTimeout,
|
|
67
|
+
reason = timedOut and "TIMEOUT_EXCEEDED" or "WITHIN_TIMEOUT"
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
--[[
|
|
72
|
+
Check if a build is eligible for timeout action
|
|
73
|
+
@param buildId - Current build ID
|
|
74
|
+
@param runningBuildId - Build ID from running key (or nil)
|
|
75
|
+
@param buildConfigExists - Whether build config still exists
|
|
76
|
+
@return {eligible, reason, shouldCleanup}
|
|
77
|
+
]]
|
|
78
|
+
function TimeoutDecider.isEligibleForTimeout(buildId, runningBuildId, buildConfigExists)
|
|
79
|
+
-- Parse buildId to number for comparison (may be string from Redis keys)
|
|
80
|
+
local buildIdNum = tonumber(buildId)
|
|
81
|
+
|
|
82
|
+
-- Build config deleted = build already completed
|
|
83
|
+
if not buildConfigExists then
|
|
84
|
+
return {
|
|
85
|
+
eligible = false,
|
|
86
|
+
reason = "BUILD_COMPLETED",
|
|
87
|
+
shouldCleanup = true
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
-- Check for nil or cjson.null (JavaScript null becomes cjson.null)
|
|
92
|
+
if not runningBuildId or runningBuildId == cjson.null then
|
|
93
|
+
return {
|
|
94
|
+
eligible = false,
|
|
95
|
+
reason = "NO_RUNNING_BUILD",
|
|
96
|
+
shouldCleanup = true
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
-- Different build running = this build not running anymore
|
|
101
|
+
if tonumber(runningBuildId) ~= buildIdNum then
|
|
102
|
+
return {
|
|
103
|
+
eligible = false,
|
|
104
|
+
reason = "NOT_RUNNING",
|
|
105
|
+
shouldCleanup = true
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
-- Running build ID matches = eligible for timeout
|
|
110
|
+
return {
|
|
111
|
+
eligible = true,
|
|
112
|
+
reason = "ELIGIBLE",
|
|
113
|
+
shouldCleanup = false
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
--[[
|
|
118
|
+
Compute action to take based on timeout check
|
|
119
|
+
@param timeoutCheck - Result from hasTimedOut()
|
|
120
|
+
@param eligibilityCheck - Result from isEligibleForTimeout()
|
|
121
|
+
@param timeoutMinutes - Timeout duration for logging
|
|
122
|
+
@return {action, reason, timeoutMinutes}
|
|
123
|
+
]]
|
|
124
|
+
function TimeoutDecider.computeAction(timeoutCheck, eligibilityCheck, timeoutMinutes)
|
|
125
|
+
-- If not eligible, determine action based on cleanup need
|
|
126
|
+
if not eligibilityCheck.eligible then
|
|
127
|
+
if eligibilityCheck.shouldCleanup then
|
|
128
|
+
return {
|
|
129
|
+
action = "CLEANUP",
|
|
130
|
+
reason = eligibilityCheck.reason,
|
|
131
|
+
shouldCleanup = true
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
return {
|
|
135
|
+
action = "SKIP",
|
|
136
|
+
reason = eligibilityCheck.reason,
|
|
137
|
+
shouldCleanup = false
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
-- Eligible - check if timed out
|
|
143
|
+
if timeoutCheck.hasTimedOut then
|
|
144
|
+
return {
|
|
145
|
+
action = "TIMEOUT",
|
|
146
|
+
reason = "BUILD_TIMEOUT",
|
|
147
|
+
timeoutMinutes = timeoutCheck.effectiveTimeout,
|
|
148
|
+
elapsedMinutes = timeoutCheck.elapsedMinutes
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
-- Eligible but not timed out yet
|
|
153
|
+
return {
|
|
154
|
+
action = "SKIP",
|
|
155
|
+
reason = "WITHIN_TIMEOUT",
|
|
156
|
+
elapsedMinutes = timeoutCheck.elapsedMinutes,
|
|
157
|
+
timeoutMinutes = timeoutCheck.effectiveTimeout
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return TimeoutDecider
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
--[[
|
|
2
|
+
startBuild.lua - Atomic build start check
|
|
3
|
+
|
|
4
|
+
This script atomically checks if a build can start and updates Redis state accordingly.
|
|
5
|
+
|
|
6
|
+
ARGV[1] = buildId (string)
|
|
7
|
+
ARGV[2] = jobId (string)
|
|
8
|
+
ARGV[3] = blockedBy (JSON array string, e.g., "[123, 456]" or "[]")
|
|
9
|
+
ARGV[4] = collapseEnabled (string: "true" or "false")
|
|
10
|
+
ARGV[5] = blockedBySelf (string: "true" or "false")
|
|
11
|
+
ARGV[6] = queuePrefix (string, e.g., "buildConfig_")
|
|
12
|
+
ARGV[7] = runningJobsPrefix (string, e.g., "running_job_")
|
|
13
|
+
ARGV[8] = waitingJobsPrefix (string, e.g., "waiting_job_")
|
|
14
|
+
ARGV[9] = blockTimeout (number, in minutes, e.g., 90)
|
|
15
|
+
|
|
16
|
+
Returns: JSON string with decision
|
|
17
|
+
{
|
|
18
|
+
action: "START" | "BLOCK" | "COLLAPSE" | "ABORT",
|
|
19
|
+
reason: string,
|
|
20
|
+
buildId: string,
|
|
21
|
+
data: {...}
|
|
22
|
+
}
|
|
23
|
+
]]
|
|
24
|
+
|
|
25
|
+
local buildId = ARGV[1]
|
|
26
|
+
local jobId = ARGV[2]
|
|
27
|
+
local blockedByJson = ARGV[3]
|
|
28
|
+
local collapseEnabled = ARGV[4] == "true"
|
|
29
|
+
local blockedBySelf = ARGV[5] == "true"
|
|
30
|
+
local queuePrefix = ARGV[6]
|
|
31
|
+
local runningJobsPrefix = ARGV[7]
|
|
32
|
+
local waitingJobsPrefix = ARGV[8]
|
|
33
|
+
local blockTimeout = tonumber(ARGV[9])
|
|
34
|
+
|
|
35
|
+
local buildConfigKey = queuePrefix .. "buildConfigs"
|
|
36
|
+
local runningKey = runningJobsPrefix .. jobId
|
|
37
|
+
local lastRunningKey = "last_" .. runningJobsPrefix .. jobId
|
|
38
|
+
local waitingKey = waitingJobsPrefix .. jobId
|
|
39
|
+
local deleteKey = "deleted_" .. jobId .. "_" .. buildId
|
|
40
|
+
|
|
41
|
+
local buildConfig = redis.call("HGET", buildConfigKey, buildId)
|
|
42
|
+
local runningBuildId = redis.call("GET", runningKey)
|
|
43
|
+
local waitingBuilds = redis.call("LRANGE", waitingKey, 0, -1)
|
|
44
|
+
local lastRunningBuildId = redis.call("GET", lastRunningKey)
|
|
45
|
+
local deleteKeyExists = redis.call("EXISTS", deleteKey)
|
|
46
|
+
|
|
47
|
+
-- Check if build was aborted (deleteKey exists)
|
|
48
|
+
local isAborted = (deleteKeyExists == 1)
|
|
49
|
+
|
|
50
|
+
-- Parse blockedBy dependencies
|
|
51
|
+
local blockedBy = cjson.decode(blockedByJson)
|
|
52
|
+
local dependencies = {}
|
|
53
|
+
if type(blockedBy) == "table" and #blockedBy > 0 then
|
|
54
|
+
dependencies = blockedBy
|
|
55
|
+
elseif blockedBy then
|
|
56
|
+
dependencies = {blockedBy}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
-- Check which dependencies are currently running
|
|
60
|
+
local runningBuilds = {}
|
|
61
|
+
for _, depJobId in ipairs(dependencies) do
|
|
62
|
+
local depRunningBuild = redis.call("GET", runningJobsPrefix .. tostring(depJobId))
|
|
63
|
+
if depRunningBuild then
|
|
64
|
+
table.insert(runningBuilds, depRunningBuild)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
-- Helper: Check if blocked by dependencies
|
|
69
|
+
local function isBlockedByDependencies(deps, running)
|
|
70
|
+
local blockedByBuilds = {}
|
|
71
|
+
for _, dep in ipairs(deps) do
|
|
72
|
+
for _, runningId in ipairs(running) do
|
|
73
|
+
if tostring(dep) == tostring(runningId) then
|
|
74
|
+
table.insert(blockedByBuilds, dep)
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
return #blockedByBuilds > 0, blockedByBuilds
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
-- Helper: Check if blocked by same job
|
|
83
|
+
local function isBlockedBySameJob()
|
|
84
|
+
if not blockedBySelf then
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
87
|
+
if not runningBuildId then
|
|
88
|
+
return false
|
|
89
|
+
end
|
|
90
|
+
local runningId = tonumber(runningBuildId)
|
|
91
|
+
local currentId = tonumber(buildId)
|
|
92
|
+
return runningId ~= currentId
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
-- Helper: Check if should collapse
|
|
96
|
+
local function shouldCollapse()
|
|
97
|
+
if not collapseEnabled then
|
|
98
|
+
return false, nil, "COLLAPSE_DISABLED"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if not waitingBuilds or #waitingBuilds == 0 then
|
|
102
|
+
return false, nil, "NO_WAITING_BUILDS"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
local newestBuild = tonumber(waitingBuilds[#waitingBuilds])
|
|
106
|
+
local currentBuild = tonumber(buildId)
|
|
107
|
+
|
|
108
|
+
-- Check if older than last running build
|
|
109
|
+
if lastRunningBuildId then
|
|
110
|
+
local lastRunning = tonumber(lastRunningBuildId)
|
|
111
|
+
if currentBuild < lastRunning then
|
|
112
|
+
return true, newestBuild, "OLDER_THAN_LAST_RUNNING"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
-- Check if not the newest waiting build
|
|
117
|
+
if currentBuild < newestBuild then
|
|
118
|
+
return true, newestBuild, "NEWER_BUILD_EXISTS"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
return false, nil, "IS_NEWEST_BUILD"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
local isBlockedByDeps, blockedByBuilds = isBlockedByDependencies(dependencies, runningBuilds)
|
|
125
|
+
local isBlockedBySelf = isBlockedBySameJob()
|
|
126
|
+
local shouldCollapseFlag, newestBuild, collapseReason = shouldCollapse()
|
|
127
|
+
|
|
128
|
+
-- Determine final action (Priority: ABORT > COLLAPSE > BLOCK > START)
|
|
129
|
+
local action, reason, actionData
|
|
130
|
+
|
|
131
|
+
if isAborted then
|
|
132
|
+
action = "ABORT"
|
|
133
|
+
reason = "BUILD_ABORTED"
|
|
134
|
+
|
|
135
|
+
elseif shouldCollapseFlag then
|
|
136
|
+
action = "COLLAPSE"
|
|
137
|
+
reason = collapseReason
|
|
138
|
+
actionData = {newestBuild = newestBuild}
|
|
139
|
+
|
|
140
|
+
elseif isBlockedByDeps then
|
|
141
|
+
action = "BLOCK"
|
|
142
|
+
reason = "BLOCKED_BY_DEPENDENCIES"
|
|
143
|
+
actionData = {blockedBy = blockedByBuilds}
|
|
144
|
+
|
|
145
|
+
elseif isBlockedBySelf then
|
|
146
|
+
action = "BLOCK"
|
|
147
|
+
reason = "BLOCKED_BY_SAME_JOB"
|
|
148
|
+
actionData = {runningBuildId = runningBuildId}
|
|
149
|
+
|
|
150
|
+
else
|
|
151
|
+
action = "START"
|
|
152
|
+
reason = "READY"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
-- Update Redis state based on decision
|
|
156
|
+
if action == "ABORT" then
|
|
157
|
+
-- Build was aborted, no state changes needed
|
|
158
|
+
return cjson.encode({
|
|
159
|
+
action = "ABORT",
|
|
160
|
+
reason = reason,
|
|
161
|
+
buildId = buildId
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
elseif action == "COLLAPSE" then
|
|
165
|
+
-- Collapse this build - remove from configs and waiting queue
|
|
166
|
+
redis.call("HDEL", buildConfigKey, buildId)
|
|
167
|
+
redis.call("LREM", waitingKey, 0, buildId)
|
|
168
|
+
|
|
169
|
+
return cjson.encode({
|
|
170
|
+
action = "COLLAPSE",
|
|
171
|
+
reason = reason,
|
|
172
|
+
buildId = buildId,
|
|
173
|
+
newestBuild = actionData.newestBuild
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
elseif action == "BLOCK" then
|
|
177
|
+
-- Build is blocked - add to waiting queue if not already there
|
|
178
|
+
local alreadyWaiting = false
|
|
179
|
+
for _, waitingBuildId in ipairs(waitingBuilds) do
|
|
180
|
+
if tostring(waitingBuildId) == tostring(buildId) then
|
|
181
|
+
alreadyWaiting = true
|
|
182
|
+
break
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if not alreadyWaiting then
|
|
187
|
+
redis.call("RPUSH", waitingKey, buildId)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
-- Set deleteKey with timeout (prevents zombie builds)
|
|
191
|
+
redis.call("SET", deleteKey, buildId, "EX", blockTimeout * 60)
|
|
192
|
+
|
|
193
|
+
return cjson.encode({
|
|
194
|
+
action = "BLOCK",
|
|
195
|
+
reason = reason,
|
|
196
|
+
buildId = buildId,
|
|
197
|
+
blockedBy = actionData and actionData.blockedBy or nil,
|
|
198
|
+
runningBuildId = actionData and actionData.runningBuildId or nil
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
else -- START
|
|
202
|
+
-- Build can start - update running keys and clean up
|
|
203
|
+
redis.call("SET", runningKey, buildId, "EX", blockTimeout * 60)
|
|
204
|
+
redis.call("SET", lastRunningKey, buildId, "EX", blockTimeout * 60 * 2)
|
|
205
|
+
|
|
206
|
+
-- Remove from waiting queue if present
|
|
207
|
+
redis.call("LREM", waitingKey, 0, buildId)
|
|
208
|
+
|
|
209
|
+
-- Delete the deleteKey (no longer needed)
|
|
210
|
+
redis.call("DEL", deleteKey)
|
|
211
|
+
|
|
212
|
+
return cjson.encode({
|
|
213
|
+
action = "START",
|
|
214
|
+
reason = reason,
|
|
215
|
+
buildId = buildId
|
|
216
|
+
})
|
|
217
|
+
end
|