screwdriver-queue-service 5.0.3 → 6.0.1

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.
@@ -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