From 92f564ab45cb8edcd7bb8d4ee208361b4b130850 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 16 Jun 2026 01:34:41 +0100 Subject: [PATCH 1/3] feat: Action grouping for concurrency limit and queuing --- .../action_customization/concurrency.adoc | 28 +- frontend/js/websocket.js | 20 +- .../gen/olivetin/api/v1/olivetin_pb.d.ts | 10 + .../gen/olivetin/api/v1/olivetin_pb.js | 2 +- frontend/resources/vue/ActionButton.vue | 13 + proto/olivetin/api/v1/olivetin.proto | 2 + service/gen/olivetin/api/v1/olivetin.pb.go | 22 +- service/internal/api/api.go | 2 + service/internal/config/config.go | 8 + service/internal/config/sanitize.go | 42 +++ service/internal/config/sanitize_test.go | 18 ++ service/internal/executor/executor.go | 91 ++++-- .../internal/executor/group_concurrency.go | 182 ++++++++++++ .../executor/group_concurrency_test.go | 277 ++++++++++++++++++ specs/action-group-concurrency.md | 19 ++ 15 files changed, 704 insertions(+), 32 deletions(-) create mode 100644 service/internal/executor/group_concurrency.go create mode 100644 service/internal/executor/group_concurrency_test.go create mode 100644 specs/action-group-concurrency.md diff --git a/docs/modules/ROOT/pages/action_customization/concurrency.adoc b/docs/modules/ROOT/pages/action_customization/concurrency.adoc index 2d63bbb..f829299 100644 --- a/docs/modules/ROOT/pages/action_customization/concurrency.adoc +++ b/docs/modules/ROOT/pages/action_customization/concurrency.adoc @@ -3,7 +3,7 @@ By default, OliveTin will allow you to run several instances of an action at the same time. For example, an action might take 20 seconds, and if you click the button 3 times, for a time there will be 3 actions running at the same time. -Sometimes you don't want to allow this - an example case where it would not make sense is in the case of a backup script. To stop this, we can set `maxConcurrent` to `1`. +Sometimes you don't want to allow this - an example case where it would not make sense is in the case of a backup script. To stop this, we can set `maxConcurrent` to `1`. [source,yaml] ---- @@ -29,4 +29,30 @@ WARN Blocked from executing. This would mean this action is running 2 times conc Naturally, you can set `maxConcurrent` to `3` or some other number, to limit the amount of times the action executes at once. +== Action groups +Sometimes you need to limit concurrency across several different actions. For example, Unity only allows one build at a time, but you might have separate actions for different platforms. + +Use `actionGroups` to define a shared limit, and assign actions to a group with `groups`: + +[source,yaml] +---- +actionGroups: + unity: + maxConcurrent: 1 + +actions: + - title: Unity Android Build + shell: /opt/unity/build-android.sh + groups: [ unity ] + + - title: Unity iOS Build + shell: /opt/unity/build-ios.sh + groups: [ unity ] +---- + +When the group limit is reached, additional requests are queued automatically and run in order when a slot becomes free. Queued executions appear in the logs with a queued status. + +Per-action `maxConcurrent` still applies separately. If the same action binding is started twice while one is already running, the second request is blocked immediately (not queued). + +The queue is held in memory. If OliveTin restarts while actions are queued, those queued requests are not preserved. diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index 6583e56..1b014c5 100644 --- a/frontend/js/websocket.js +++ b/frontend/js/websocket.js @@ -194,11 +194,27 @@ async function handleConfigChangedEvent (j) { window.dispatchEvent(j) } +const eventCaseToTypeName = { + entityChanged: 'EventEntityChanged', + configChanged: 'EventConfigChanged', + executionFinished: 'EventExecutionFinished', + executionStarted: 'EventExecutionStarted', + outputChunk: 'EventOutputChunk', + heartbeat: 'EventHeartbeat' +} + function handleEvent (msg) { - const typeName = msg.event.value.$typeName.replace('olivetin.api.v1.', '') + const eventCase = msg?.event?.case + const eventValue = msg?.event?.value + const typeName = eventCaseToTypeName[eventCase] + + if (!typeName || !eventValue) { + console.warn('Skipping websocket event with no payload:', msg) + return + } const j = new Event(typeName) - j.payload = msg.event.value + j.payload = eventValue switch (typeName) { case 'EventConfigChanged': diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts index 94d7386..ee2680d 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts +++ b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts @@ -673,6 +673,16 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & { * @generated from field: string binding_id = 20; */ bindingId: string; + + /** + * @generated from field: bool queued = 21; + */ + queued: boolean; + + /** + * @generated from field: string queued_for_group = 22; + */ + queuedForGroup: string; }; /** diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js index 314efdf..e3c5f87 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js +++ b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js @@ -8,7 +8,7 @@ import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2 * Describes the file olivetin/api/v1/olivetin.proto. */ export const file_olivetin_api_v1_olivetin = /*@__PURE__*/ - fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSK2AwoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkSFwoPZXhlY19vbl9zdGFydHVwGAogASgIEhQKDGV4ZWNfb25fY3JvbhgLIAMoCRIjChtleGVjX29uX2ZpbGVfY3JlYXRlZF9pbl9kaXIYDCADKAkSIwobZXhlY19vbl9maWxlX2NoYW5nZWRfaW5fZGlyGA0gAygJEh0KFWV4ZWNfb25fY2FsZW5kYXJfZmlsZRgOIAEoCRJAChBleGVjX29uX3dlYmhvb2tzGA8gAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludCLDAgoVQWN0aW9uV2ViaG9va0V4ZWNIaW50EhAKCHRlbXBsYXRlGAEgASgJEhIKCm1hdGNoX3BhdGgYAiABKAkSTwoNbWF0Y2hfaGVhZGVycxgDIAMoCzI4Lm9saXZldGluLmFwaS52MS5BY3Rpb25XZWJob29rRXhlY0hpbnQuTWF0Y2hIZWFkZXJzRW50cnkSSwoLbWF0Y2hfcXVlcnkYBCADKAsyNi5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uV2ViaG9va0V4ZWNIaW50Lk1hdGNoUXVlcnlFbnRyeRozChFNYXRjaEhlYWRlcnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBGjEKD01hdGNoUXVlcnlFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIrsCCg5BY3Rpb25Bcmd1bWVudBIMCgRuYW1lGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBHR5cGUYAyABKAkSFQoNZGVmYXVsdF92YWx1ZRgEIAEoCRI2CgdjaG9pY2VzGAUgAygLMiUub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkFyZ3VtZW50Q2hvaWNlEhMKC2Rlc2NyaXB0aW9uGAYgASgJEkUKC3N1Z2dlc3Rpb25zGAcgAygLMjAub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkFyZ3VtZW50LlN1Z2dlc3Rpb25zRW50cnkSHwoXc3VnZ2VzdGlvbnNfYnJvd3Nlcl9rZXkYCCABKAkaMgoQU3VnZ2VzdGlvbnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIjQKFEFjdGlvbkFyZ3VtZW50Q2hvaWNlEg0KBXZhbHVlGAEgASgJEg0KBXRpdGxlGAIgASgJIrIBCgZFbnRpdHkSDQoFdGl0bGUYASABKAkSEgoKdW5pcXVlX2tleRgCIAEoCRIMCgR0eXBlGAMgASgJEhMKC2RpcmVjdG9yaWVzGAQgAygJEjMKBmZpZWxkcxgFIAMoCzIjLm9saXZldGluLmFwaS52MS5FbnRpdHkuRmllbGRzRW50cnkaLQoLRmllbGRzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJUChRHZXREYXNoYm9hcmRSZXNwb25zZRINCgV0aXRsZRgBIAEoCRItCglkYXNoYm9hcmQYBCABKAsyGi5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkIl8KD0VmZmVjdGl2ZVBvbGljeRIYChBzaG93X2RpYWdub3N0aWNzGAEgASgIEhUKDXNob3dfbG9nX2xpc3QYAiABKAgSGwoTc2hvd192ZXJzaW9uX251bWJlchgDIAEoCCJNChNHZXREYXNoYm9hcmRSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEhMKC2VudGl0eV90eXBlGAIgASgJEhIKCmVudGl0eV9rZXkYAyABKAkiUQoJRGFzaGJvYXJkEg0KBXRpdGxlGAEgASgJEjUKCGNvbnRlbnRzGAIgAygLMiMub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZENvbXBvbmVudCLbAQoSRGFzaGJvYXJkQ29tcG9uZW50Eg0KBXRpdGxlGAEgASgJEgwKBHR5cGUYAiABKAkSNQoIY29udGVudHMYAyADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50EgwKBGljb24YBCABKAkSEQoJY3NzX2NsYXNzGAUgASgJEicKBmFjdGlvbhgGIAEoCzIXLm9saXZldGluLmFwaS52MS5BY3Rpb24SEwoLZW50aXR5X3R5cGUYByABKAkSEgoKZW50aXR5X2tleRgIIAEoCSJ9ChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkiMgoTU3RhcnRBY3Rpb25Bcmd1bWVudBIMCgRuYW1lGAEgASgJEg0KBXZhbHVlGAIgASgJIjQKE1N0YXJ0QWN0aW9uUmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAIgASgJImcKGVN0YXJ0QWN0aW9uQW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJEjcKCWFyZ3VtZW50cxgCIAMoCzIkLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFyZ3VtZW50IkoKGlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIsChdTdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkiOQoYU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgCIAEoCSIzCh5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIk8KH1N0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5Ik4KDkdldExvZ3NSZXF1ZXN0EhQKDHN0YXJ0X29mZnNldBgBIAEoAxITCgtkYXRlX2ZpbHRlchgCIAEoCRIRCglwYWdlX3NpemUYAyABKAMimgMKCExvZ0VudHJ5EhgKEGRhdGV0aW1lX3N0YXJ0ZWQYASABKAkSFAoMYWN0aW9uX3RpdGxlGAIgASgJEg4KBm91dHB1dBgDIAEoCRIRCgl0aW1lZF9vdXQYBSABKAgSEQoJZXhpdF9jb2RlGAYgASgFEgwKBHVzZXIYByABKAkSEgoKdXNlcl9jbGFzcxgIIAEoCRITCgthY3Rpb25faWNvbhgJIAEoCRIMCgR0YWdzGAogAygJEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgLIAEoCRIZChFkYXRldGltZV9maW5pc2hlZBgMIAEoCRIZChFleGVjdXRpb25fc3RhcnRlZBgOIAEoCBIaChJleGVjdXRpb25fZmluaXNoZWQYDyABKAgSDwoHYmxvY2tlZBgQIAEoCBIWCg5kYXRldGltZV9pbmRleBgRIAEoAxIQCghjYW5fa2lsbBgSIAEoCBIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYEyABKAkSEgoKYmluZGluZ19pZBgUIAEoCSKRAQoPR2V0TG9nc1Jlc3BvbnNlEicKBGxvZ3MYASADKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSFwoPY291bnRfcmVtYWluaW5nGAIgASgDEhEKCXBhZ2Vfc2l6ZRgDIAEoAxITCgt0b3RhbF9jb3VudBgEIAEoAxIUCgxzdGFydF9vZmZzZXQYBSABKAMiPwoUR2V0QWN0aW9uTG9nc1JlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJEhQKDHN0YXJ0X29mZnNldBgCIAEoAyKXAQoVR2V0QWN0aW9uTG9nc1Jlc3BvbnNlEicKBGxvZ3MYASADKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSFwoPY291bnRfcmVtYWluaW5nGAIgASgDEhEKCXBhZ2Vfc2l6ZRgDIAEoAxITCgt0b3RhbF9jb3VudBgEIAEoAxIUCgxzdGFydF9vZmZzZXQYBSABKAMiZQobVmFsaWRhdGVBcmd1bWVudFR5cGVSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEgwKBHR5cGUYAiABKAkSEgoKYmluZGluZ19pZBgDIAEoCRIVCg1hcmd1bWVudF9uYW1lGAQgASgJIkIKHFZhbGlkYXRlQXJndW1lbnRUeXBlUmVzcG9uc2USDQoFdmFsaWQYASABKAgSEwoLZGVzY3JpcHRpb24YAiABKAkiNgoVV2F0Y2hFeGVjdXRpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSImChRXYXRjaEV4ZWN1dGlvblVwZGF0ZRIOCgZ1cGRhdGUYASABKAkiSgoWRXhlY3V0aW9uU3RhdHVzUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSEQoJYWN0aW9uX2lkGAIgASgJIkcKF0V4ZWN1dGlvblN0YXR1c1Jlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIPCg1XaG9BbUlSZXF1ZXN0ImwKDldob0FtSVJlc3BvbnNlEhoKEmF1dGhlbnRpY2F0ZWRfdXNlchgBIAEoCRIRCgl1c2VyZ3JvdXAYAiABKAkSEAoIcHJvdmlkZXIYAyABKAkSDAoEYWNscxgEIAMoCRILCgNzaWQYBSABKAkiEgoQU29zUmVwb3J0UmVxdWVzdCIiChFTb3NSZXBvcnRSZXNwb25zZRINCgVhbGVydBgBIAEoCSIRCg9EdW1wVmFyc1JlcXVlc3QilQEKEER1bXBWYXJzUmVzcG9uc2USDQoFYWxlcnQYASABKAkSQQoIY29udGVudHMYAiADKAsyLy5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXNwb25zZS5Db250ZW50c0VudHJ5Gi8KDUNvbnRlbnRzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASI7CgxEZWJ1Z0JpbmRpbmcSFAoMYWN0aW9uX3RpdGxlGAEgASgJEhUKDWVudGl0eV9wcmVmaXgYAiABKAkiHgocRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVxdWVzdCLOAQodRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2USDQoFYWxlcnQYASABKAkSTgoIY29udGVudHMYAiADKAsyPC5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2UuQ29udGVudHNFbnRyeRpOCg1Db250ZW50c0VudHJ5EgsKA2tleRgBIAEoCRIsCgV2YWx1ZRgCIAEoCzIdLm9saXZldGluLmFwaS52MS5EZWJ1Z0JpbmRpbmc6AjgBIhIKEEdldFJlYWR5elJlcXVlc3QiIwoRR2V0UmVhZHl6UmVzcG9uc2USDgoGc3RhdHVzGAEgASgJIhQKEkV2ZW50U3RyZWFtUmVxdWVzdCKZAwoTRXZlbnRTdHJlYW1SZXNwb25zZRI9Cg5lbnRpdHlfY2hhbmdlZBgCIAEoCzIjLm9saXZldGluLmFwaS52MS5FdmVudEVudGl0eUNoYW5nZWRIABI9Cg5jb25maWdfY2hhbmdlZBgDIAEoCzIjLm9saXZldGluLmFwaS52MS5FdmVudENvbmZpZ0NoYW5nZWRIABJFChJleGVjdXRpb25fZmluaXNoZWQYBCABKAsyJy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFeGVjdXRpb25GaW5pc2hlZEgAEkMKEWV4ZWN1dGlvbl9zdGFydGVkGAUgASgLMiYub2xpdmV0aW4uYXBpLnYxLkV2ZW50RXhlY3V0aW9uU3RhcnRlZEgAEjkKDG91dHB1dF9jaHVuaxgGIAEoCzIhLm9saXZldGluLmFwaS52MS5FdmVudE91dHB1dENodW5rSAASNAoJaGVhcnRiZWF0GAcgASgLMh8ub2xpdmV0aW4uYXBpLnYxLkV2ZW50SGVhcnRiZWF0SABCBwoFZXZlbnQiQQoQRXZlbnRPdXRwdXRDaHVuaxIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSDgoGb3V0cHV0GAIgASgJIhQKEkV2ZW50RW50aXR5Q2hhbmdlZCIUChJFdmVudENvbmZpZ0NoYW5nZWQiEAoORXZlbnRIZWFydGJlYXQiRgoWRXZlbnRFeGVjdXRpb25GaW5pc2hlZBIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiRQoVRXZlbnRFeGVjdXRpb25TdGFydGVkEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIyChFLaWxsQWN0aW9uUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkibQoSS2lsbEFjdGlvblJlc3BvbnNlEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIOCgZraWxsZWQYAiABKAgSGQoRYWxyZWFkeV9jb21wbGV0ZWQYAyABKAgSDQoFZm91bmQYBCABKAgiOwoVTG9jYWxVc2VyTG9naW5SZXF1ZXN0EhAKCHVzZXJuYW1lGAEgASgJEhAKCHBhc3N3b3JkGAIgASgJIikKFkxvY2FsVXNlckxvZ2luUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCInChNQYXNzd29yZEhhc2hSZXF1ZXN0EhAKCHBhc3N3b3JkGAEgASgJIiQKFFBhc3N3b3JkSGFzaFJlc3BvbnNlEgwKBGhhc2gYASABKAkiDwoNTG9nb3V0UmVxdWVzdCIQCg5Mb2dvdXRSZXNwb25zZSIXChVHZXREaWFnbm9zdGljc1JlcXVlc3QiRQoWR2V0RGlhZ25vc3RpY3NSZXNwb25zZRITCgtTc2hGb3VuZEtleRgBIAEoCRIWCg5Tc2hGb3VuZENvbmZpZxgCIAEoCSINCgtJbml0UmVxdWVzdCLrBQoMSW5pdFJlc3BvbnNlEhIKCnNob3dGb290ZXIYASABKAgSFgoOc2hvd05hdmlnYXRpb24YAiABKAgSFwoPc2hvd05ld1ZlcnNpb25zGAMgASgIEhgKEGF2YWlsYWJsZVZlcnNpb24YBCABKAkSFgoOY3VycmVudFZlcnNpb24YBSABKAkSEQoJcGFnZVRpdGxlGAYgASgJEh4KFnNlY3Rpb25OYXZpZ2F0aW9uU3R5bGUYByABKAkSGgoSZGVmYXVsdEljb25Gb3JCYWNrGAggASgJEhYKDmVuYWJsZUN1c3RvbUpzGAkgASgIEhQKDGF1dGhMb2dpblVybBgKIAEoCRIWCg5hdXRoTG9jYWxMb2dpbhgLIAEoCBIRCglzdHlsZU1vZHMYDCADKAkSOAoPb0F1dGgyUHJvdmlkZXJzGA0gAygLMh8ub2xpdmV0aW4uYXBpLnYxLk9BdXRoMlByb3ZpZGVyEjgKD2FkZGl0aW9uYWxMaW5rcxgOIAMoCzIfLm9saXZldGluLmFwaS52MS5BZGRpdGlvbmFsTGluaxIWCg5yb290RGFzaGJvYXJkcxgPIAMoCRIaChJhdXRoZW50aWNhdGVkX3VzZXIYECABKAkSIwobYXV0aGVudGljYXRlZF91c2VyX3Byb3ZpZGVyGBEgASgJEjoKEGVmZmVjdGl2ZV9wb2xpY3kYEiABKAsyIC5vbGl2ZXRpbi5hcGkudjEuRWZmZWN0aXZlUG9saWN5EhYKDmJhbm5lcl9tZXNzYWdlGBMgASgJEhIKCmJhbm5lcl9jc3MYFCABKAkSGAoQc2hvd19kaWFnbm9zdGljcxgVIAEoCBIVCg1zaG93X2xvZ19saXN0GBYgASgIEhYKDmxvZ2luX3JlcXVpcmVkGBcgASgIEhgKEGF2YWlsYWJsZV90aGVtZXMYGCADKAkSJAocc2hvd19uYXZpZ2F0ZV9vbl9zdGFydF9pY29ucxgZIAEoCCIsCg5BZGRpdGlvbmFsTGluaxINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkiOgoOT0F1dGgyUHJvdmlkZXISDQoFdGl0bGUYASABKAkSDAoEaWNvbhgDIAEoCRILCgNrZXkYBCABKAkiLQoXR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCSJDChhHZXRBY3Rpb25CaW5kaW5nUmVzcG9uc2USJwoGYWN0aW9uGAEgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbiIUChJHZXRFbnRpdGllc1JlcXVlc3QiVAoTR2V0RW50aXRpZXNSZXNwb25zZRI9ChJlbnRpdHlfZGVmaW5pdGlvbnMYASADKAsyIS5vbGl2ZXRpbi5hcGkudjEuRW50aXR5RGVmaW5pdGlvbiJpChBFbnRpdHlEZWZpbml0aW9uEg0KBXRpdGxlGAEgASgJEioKCWluc3RhbmNlcxgCIAMoCzIXLm9saXZldGluLmFwaS52MS5FbnRpdHkSGgoSdXNlZF9vbl9kYXNoYm9hcmRzGAMgAygJIjQKEEdldEVudGl0eVJlcXVlc3QSEgoKdW5pcXVlX2tleRgBIAEoCRIMCgR0eXBlGAIgASgJIjUKFFJlc3RhcnRBY3Rpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCTLoEgoST2xpdmVUaW5BcGlTZXJ2aWNlEl0KDEdldERhc2hib2FyZBIkLm9saXZldGluLmFwaS52MS5HZXREYXNoYm9hcmRSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlc3BvbnNlIgASWgoLU3RhcnRBY3Rpb24SIy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVzcG9uc2UiABJvChJTdGFydEFjdGlvbkFuZFdhaXQSKi5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBorLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXNwb25zZSIAEmkKEFN0YXJ0QWN0aW9uQnlHZXQSKC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldFJlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlIgASfgoXU3RhcnRBY3Rpb25CeUdldEFuZFdhaXQSLy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXF1ZXN0GjAub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2UiABJeCg1SZXN0YXJ0QWN0aW9uEiUub2xpdmV0aW4uYXBpLnYxLlJlc3RhcnRBY3Rpb25SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVzcG9uc2UiABJXCgpLaWxsQWN0aW9uEiIub2xpdmV0aW4uYXBpLnYxLktpbGxBY3Rpb25SZXF1ZXN0GiMub2xpdmV0aW4uYXBpLnYxLktpbGxBY3Rpb25SZXNwb25zZSIAEmYKD0V4ZWN1dGlvblN0YXR1cxInLm9saXZldGluLmFwaS52MS5FeGVjdXRpb25TdGF0dXNSZXF1ZXN0Gigub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1Jlc3BvbnNlIgASTgoHR2V0TG9ncxIfLm9saXZldGluLmFwaS52MS5HZXRMb2dzUmVxdWVzdBogLm9saXZldGluLmFwaS52MS5HZXRMb2dzUmVzcG9uc2UiABJgCg1HZXRBY3Rpb25Mb2dzEiUub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkxvZ3NSZXF1ZXN0GiYub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkxvZ3NSZXNwb25zZSIAEnUKFFZhbGlkYXRlQXJndW1lbnRUeXBlEiwub2xpdmV0aW4uYXBpLnYxLlZhbGlkYXRlQXJndW1lbnRUeXBlUmVxdWVzdBotLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlc3BvbnNlIgASSwoGV2hvQW1JEh4ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuV2hvQW1JUmVzcG9uc2UiABJUCglTb3NSZXBvcnQSIS5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVxdWVzdBoiLm9saXZldGluLmFwaS52MS5Tb3NSZXBvcnRSZXNwb25zZSIAElEKCER1bXBWYXJzEiAub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVxdWVzdBohLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1Jlc3BvbnNlIgASeAoVRHVtcFB1YmxpY0lkQWN0aW9uTWFwEi0ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlcXVlc3QaLi5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2UiABJUCglHZXRSZWFkeXoSIS5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVxdWVzdBoiLm9saXZldGluLmFwaS52MS5HZXRSZWFkeXpSZXNwb25zZSIAEmMKDkxvY2FsVXNlckxvZ2luEiYub2xpdmV0aW4uYXBpLnYxLkxvY2FsVXNlckxvZ2luUmVxdWVzdBonLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlc3BvbnNlIgASXQoMUGFzc3dvcmRIYXNoEiQub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuUGFzc3dvcmRIYXNoUmVzcG9uc2UiABJLCgZMb2dvdXQSHi5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVxdWVzdBofLm9saXZldGluLmFwaS52MS5Mb2dvdXRSZXNwb25zZSIAElwKC0V2ZW50U3RyZWFtEiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50U3RyZWFtUmVxdWVzdBokLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlc3BvbnNlIgAwARJjCg5HZXREaWFnbm9zdGljcxImLm9saXZldGluLmFwaS52MS5HZXREaWFnbm9zdGljc1JlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXNwb25zZSIAEkUKBEluaXQSHC5vbGl2ZXRpbi5hcGkudjEuSW5pdFJlcXVlc3QaHS5vbGl2ZXRpbi5hcGkudjEuSW5pdFJlc3BvbnNlIgASaQoQR2V0QWN0aW9uQmluZGluZxIoLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25CaW5kaW5nUmVxdWVzdBopLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25CaW5kaW5nUmVzcG9uc2UiABJaCgtHZXRFbnRpdGllcxIjLm9saXZldGluLmFwaS52MS5HZXRFbnRpdGllc1JlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXNwb25zZSIAEkkKCUdldEVudGl0eRIhLm9saXZldGluLmFwaS52MS5HZXRFbnRpdHlSZXF1ZXN0Ghcub2xpdmV0aW4uYXBpLnYxLkVudGl0eSIAQjhaNmdpdGh1Yi5jb20vT2xpdmVUaW4vT2xpdmVUaW4vZ2VuL29saXZldGluL2FwaS92MTthcGl2MWIGcHJvdG8z"); + fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSK2AwoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkSFwoPZXhlY19vbl9zdGFydHVwGAogASgIEhQKDGV4ZWNfb25fY3JvbhgLIAMoCRIjChtleGVjX29uX2ZpbGVfY3JlYXRlZF9pbl9kaXIYDCADKAkSIwobZXhlY19vbl9maWxlX2NoYW5nZWRfaW5fZGlyGA0gAygJEh0KFWV4ZWNfb25fY2FsZW5kYXJfZmlsZRgOIAEoCRJAChBleGVjX29uX3dlYmhvb2tzGA8gAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludCLDAgoVQWN0aW9uV2ViaG9va0V4ZWNIaW50EhAKCHRlbXBsYXRlGAEgASgJEhIKCm1hdGNoX3BhdGgYAiABKAkSTwoNbWF0Y2hfaGVhZGVycxgDIAMoCzI4Lm9saXZldGluLmFwaS52MS5BY3Rpb25XZWJob29rRXhlY0hpbnQuTWF0Y2hIZWFkZXJzRW50cnkSSwoLbWF0Y2hfcXVlcnkYBCADKAsyNi5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uV2ViaG9va0V4ZWNIaW50Lk1hdGNoUXVlcnlFbnRyeRozChFNYXRjaEhlYWRlcnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBGjEKD01hdGNoUXVlcnlFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIrsCCg5BY3Rpb25Bcmd1bWVudBIMCgRuYW1lGAEgASgJEg0KBXRpdGxlGAIgASgJEgwKBHR5cGUYAyABKAkSFQoNZGVmYXVsdF92YWx1ZRgEIAEoCRI2CgdjaG9pY2VzGAUgAygLMiUub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkFyZ3VtZW50Q2hvaWNlEhMKC2Rlc2NyaXB0aW9uGAYgASgJEkUKC3N1Z2dlc3Rpb25zGAcgAygLMjAub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkFyZ3VtZW50LlN1Z2dlc3Rpb25zRW50cnkSHwoXc3VnZ2VzdGlvbnNfYnJvd3Nlcl9rZXkYCCABKAkaMgoQU3VnZ2VzdGlvbnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIjQKFEFjdGlvbkFyZ3VtZW50Q2hvaWNlEg0KBXZhbHVlGAEgASgJEg0KBXRpdGxlGAIgASgJIrIBCgZFbnRpdHkSDQoFdGl0bGUYASABKAkSEgoKdW5pcXVlX2tleRgCIAEoCRIMCgR0eXBlGAMgASgJEhMKC2RpcmVjdG9yaWVzGAQgAygJEjMKBmZpZWxkcxgFIAMoCzIjLm9saXZldGluLmFwaS52MS5FbnRpdHkuRmllbGRzRW50cnkaLQoLRmllbGRzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJUChRHZXREYXNoYm9hcmRSZXNwb25zZRINCgV0aXRsZRgBIAEoCRItCglkYXNoYm9hcmQYBCABKAsyGi5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkIl8KD0VmZmVjdGl2ZVBvbGljeRIYChBzaG93X2RpYWdub3N0aWNzGAEgASgIEhUKDXNob3dfbG9nX2xpc3QYAiABKAgSGwoTc2hvd192ZXJzaW9uX251bWJlchgDIAEoCCJNChNHZXREYXNoYm9hcmRSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEhMKC2VudGl0eV90eXBlGAIgASgJEhIKCmVudGl0eV9rZXkYAyABKAkiUQoJRGFzaGJvYXJkEg0KBXRpdGxlGAEgASgJEjUKCGNvbnRlbnRzGAIgAygLMiMub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZENvbXBvbmVudCLbAQoSRGFzaGJvYXJkQ29tcG9uZW50Eg0KBXRpdGxlGAEgASgJEgwKBHR5cGUYAiABKAkSNQoIY29udGVudHMYAyADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50EgwKBGljb24YBCABKAkSEQoJY3NzX2NsYXNzGAUgASgJEicKBmFjdGlvbhgGIAEoCzIXLm9saXZldGluLmFwaS52MS5BY3Rpb24SEwoLZW50aXR5X3R5cGUYByABKAkSEgoKZW50aXR5X2tleRgIIAEoCSJ9ChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkiMgoTU3RhcnRBY3Rpb25Bcmd1bWVudBIMCgRuYW1lGAEgASgJEg0KBXZhbHVlGAIgASgJIjQKE1N0YXJ0QWN0aW9uUmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAIgASgJImcKGVN0YXJ0QWN0aW9uQW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJEjcKCWFyZ3VtZW50cxgCIAMoCzIkLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFyZ3VtZW50IkoKGlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIsChdTdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkiOQoYU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgCIAEoCSIzCh5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIk8KH1N0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5Ik4KDkdldExvZ3NSZXF1ZXN0EhQKDHN0YXJ0X29mZnNldBgBIAEoAxITCgtkYXRlX2ZpbHRlchgCIAEoCRIRCglwYWdlX3NpemUYAyABKAMixAMKCExvZ0VudHJ5EhgKEGRhdGV0aW1lX3N0YXJ0ZWQYASABKAkSFAoMYWN0aW9uX3RpdGxlGAIgASgJEg4KBm91dHB1dBgDIAEoCRIRCgl0aW1lZF9vdXQYBSABKAgSEQoJZXhpdF9jb2RlGAYgASgFEgwKBHVzZXIYByABKAkSEgoKdXNlcl9jbGFzcxgIIAEoCRITCgthY3Rpb25faWNvbhgJIAEoCRIMCgR0YWdzGAogAygJEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgLIAEoCRIZChFkYXRldGltZV9maW5pc2hlZBgMIAEoCRIZChFleGVjdXRpb25fc3RhcnRlZBgOIAEoCBIaChJleGVjdXRpb25fZmluaXNoZWQYDyABKAgSDwoHYmxvY2tlZBgQIAEoCBIWCg5kYXRldGltZV9pbmRleBgRIAEoAxIQCghjYW5fa2lsbBgSIAEoCBIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYEyABKAkSEgoKYmluZGluZ19pZBgUIAEoCRIOCgZxdWV1ZWQYFSABKAgSGAoQcXVldWVkX2Zvcl9ncm91cBgWIAEoCSKRAQoPR2V0TG9nc1Jlc3BvbnNlEicKBGxvZ3MYASADKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSFwoPY291bnRfcmVtYWluaW5nGAIgASgDEhEKCXBhZ2Vfc2l6ZRgDIAEoAxITCgt0b3RhbF9jb3VudBgEIAEoAxIUCgxzdGFydF9vZmZzZXQYBSABKAMiPwoUR2V0QWN0aW9uTG9nc1JlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJEhQKDHN0YXJ0X29mZnNldBgCIAEoAyKXAQoVR2V0QWN0aW9uTG9nc1Jlc3BvbnNlEicKBGxvZ3MYASADKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSFwoPY291bnRfcmVtYWluaW5nGAIgASgDEhEKCXBhZ2Vfc2l6ZRgDIAEoAxITCgt0b3RhbF9jb3VudBgEIAEoAxIUCgxzdGFydF9vZmZzZXQYBSABKAMiZQobVmFsaWRhdGVBcmd1bWVudFR5cGVSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEgwKBHR5cGUYAiABKAkSEgoKYmluZGluZ19pZBgDIAEoCRIVCg1hcmd1bWVudF9uYW1lGAQgASgJIkIKHFZhbGlkYXRlQXJndW1lbnRUeXBlUmVzcG9uc2USDQoFdmFsaWQYASABKAgSEwoLZGVzY3JpcHRpb24YAiABKAkiNgoVV2F0Y2hFeGVjdXRpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSImChRXYXRjaEV4ZWN1dGlvblVwZGF0ZRIOCgZ1cGRhdGUYASABKAkiSgoWRXhlY3V0aW9uU3RhdHVzUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSEQoJYWN0aW9uX2lkGAIgASgJIkcKF0V4ZWN1dGlvblN0YXR1c1Jlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIPCg1XaG9BbUlSZXF1ZXN0ImwKDldob0FtSVJlc3BvbnNlEhoKEmF1dGhlbnRpY2F0ZWRfdXNlchgBIAEoCRIRCgl1c2VyZ3JvdXAYAiABKAkSEAoIcHJvdmlkZXIYAyABKAkSDAoEYWNscxgEIAMoCRILCgNzaWQYBSABKAkiEgoQU29zUmVwb3J0UmVxdWVzdCIiChFTb3NSZXBvcnRSZXNwb25zZRINCgVhbGVydBgBIAEoCSIRCg9EdW1wVmFyc1JlcXVlc3QilQEKEER1bXBWYXJzUmVzcG9uc2USDQoFYWxlcnQYASABKAkSQQoIY29udGVudHMYAiADKAsyLy5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXNwb25zZS5Db250ZW50c0VudHJ5Gi8KDUNvbnRlbnRzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASI7CgxEZWJ1Z0JpbmRpbmcSFAoMYWN0aW9uX3RpdGxlGAEgASgJEhUKDWVudGl0eV9wcmVmaXgYAiABKAkiHgocRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVxdWVzdCLOAQodRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2USDQoFYWxlcnQYASABKAkSTgoIY29udGVudHMYAiADKAsyPC5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2UuQ29udGVudHNFbnRyeRpOCg1Db250ZW50c0VudHJ5EgsKA2tleRgBIAEoCRIsCgV2YWx1ZRgCIAEoCzIdLm9saXZldGluLmFwaS52MS5EZWJ1Z0JpbmRpbmc6AjgBIhIKEEdldFJlYWR5elJlcXVlc3QiIwoRR2V0UmVhZHl6UmVzcG9uc2USDgoGc3RhdHVzGAEgASgJIhQKEkV2ZW50U3RyZWFtUmVxdWVzdCKZAwoTRXZlbnRTdHJlYW1SZXNwb25zZRI9Cg5lbnRpdHlfY2hhbmdlZBgCIAEoCzIjLm9saXZldGluLmFwaS52MS5FdmVudEVudGl0eUNoYW5nZWRIABI9Cg5jb25maWdfY2hhbmdlZBgDIAEoCzIjLm9saXZldGluLmFwaS52MS5FdmVudENvbmZpZ0NoYW5nZWRIABJFChJleGVjdXRpb25fZmluaXNoZWQYBCABKAsyJy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFeGVjdXRpb25GaW5pc2hlZEgAEkMKEWV4ZWN1dGlvbl9zdGFydGVkGAUgASgLMiYub2xpdmV0aW4uYXBpLnYxLkV2ZW50RXhlY3V0aW9uU3RhcnRlZEgAEjkKDG91dHB1dF9jaHVuaxgGIAEoCzIhLm9saXZldGluLmFwaS52MS5FdmVudE91dHB1dENodW5rSAASNAoJaGVhcnRiZWF0GAcgASgLMh8ub2xpdmV0aW4uYXBpLnYxLkV2ZW50SGVhcnRiZWF0SABCBwoFZXZlbnQiQQoQRXZlbnRPdXRwdXRDaHVuaxIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSDgoGb3V0cHV0GAIgASgJIhQKEkV2ZW50RW50aXR5Q2hhbmdlZCIUChJFdmVudENvbmZpZ0NoYW5nZWQiEAoORXZlbnRIZWFydGJlYXQiRgoWRXZlbnRFeGVjdXRpb25GaW5pc2hlZBIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiRQoVRXZlbnRFeGVjdXRpb25TdGFydGVkEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIyChFLaWxsQWN0aW9uUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkibQoSS2lsbEFjdGlvblJlc3BvbnNlEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIOCgZraWxsZWQYAiABKAgSGQoRYWxyZWFkeV9jb21wbGV0ZWQYAyABKAgSDQoFZm91bmQYBCABKAgiOwoVTG9jYWxVc2VyTG9naW5SZXF1ZXN0EhAKCHVzZXJuYW1lGAEgASgJEhAKCHBhc3N3b3JkGAIgASgJIikKFkxvY2FsVXNlckxvZ2luUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCInChNQYXNzd29yZEhhc2hSZXF1ZXN0EhAKCHBhc3N3b3JkGAEgASgJIiQKFFBhc3N3b3JkSGFzaFJlc3BvbnNlEgwKBGhhc2gYASABKAkiDwoNTG9nb3V0UmVxdWVzdCIQCg5Mb2dvdXRSZXNwb25zZSIXChVHZXREaWFnbm9zdGljc1JlcXVlc3QiRQoWR2V0RGlhZ25vc3RpY3NSZXNwb25zZRITCgtTc2hGb3VuZEtleRgBIAEoCRIWCg5Tc2hGb3VuZENvbmZpZxgCIAEoCSINCgtJbml0UmVxdWVzdCLrBQoMSW5pdFJlc3BvbnNlEhIKCnNob3dGb290ZXIYASABKAgSFgoOc2hvd05hdmlnYXRpb24YAiABKAgSFwoPc2hvd05ld1ZlcnNpb25zGAMgASgIEhgKEGF2YWlsYWJsZVZlcnNpb24YBCABKAkSFgoOY3VycmVudFZlcnNpb24YBSABKAkSEQoJcGFnZVRpdGxlGAYgASgJEh4KFnNlY3Rpb25OYXZpZ2F0aW9uU3R5bGUYByABKAkSGgoSZGVmYXVsdEljb25Gb3JCYWNrGAggASgJEhYKDmVuYWJsZUN1c3RvbUpzGAkgASgIEhQKDGF1dGhMb2dpblVybBgKIAEoCRIWCg5hdXRoTG9jYWxMb2dpbhgLIAEoCBIRCglzdHlsZU1vZHMYDCADKAkSOAoPb0F1dGgyUHJvdmlkZXJzGA0gAygLMh8ub2xpdmV0aW4uYXBpLnYxLk9BdXRoMlByb3ZpZGVyEjgKD2FkZGl0aW9uYWxMaW5rcxgOIAMoCzIfLm9saXZldGluLmFwaS52MS5BZGRpdGlvbmFsTGluaxIWCg5yb290RGFzaGJvYXJkcxgPIAMoCRIaChJhdXRoZW50aWNhdGVkX3VzZXIYECABKAkSIwobYXV0aGVudGljYXRlZF91c2VyX3Byb3ZpZGVyGBEgASgJEjoKEGVmZmVjdGl2ZV9wb2xpY3kYEiABKAsyIC5vbGl2ZXRpbi5hcGkudjEuRWZmZWN0aXZlUG9saWN5EhYKDmJhbm5lcl9tZXNzYWdlGBMgASgJEhIKCmJhbm5lcl9jc3MYFCABKAkSGAoQc2hvd19kaWFnbm9zdGljcxgVIAEoCBIVCg1zaG93X2xvZ19saXN0GBYgASgIEhYKDmxvZ2luX3JlcXVpcmVkGBcgASgIEhgKEGF2YWlsYWJsZV90aGVtZXMYGCADKAkSJAocc2hvd19uYXZpZ2F0ZV9vbl9zdGFydF9pY29ucxgZIAEoCCIsCg5BZGRpdGlvbmFsTGluaxINCgV0aXRsZRgBIAEoCRILCgN1cmwYAiABKAkiOgoOT0F1dGgyUHJvdmlkZXISDQoFdGl0bGUYASABKAkSDAoEaWNvbhgDIAEoCRILCgNrZXkYBCABKAkiLQoXR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCSJDChhHZXRBY3Rpb25CaW5kaW5nUmVzcG9uc2USJwoGYWN0aW9uGAEgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbiIUChJHZXRFbnRpdGllc1JlcXVlc3QiVAoTR2V0RW50aXRpZXNSZXNwb25zZRI9ChJlbnRpdHlfZGVmaW5pdGlvbnMYASADKAsyIS5vbGl2ZXRpbi5hcGkudjEuRW50aXR5RGVmaW5pdGlvbiJpChBFbnRpdHlEZWZpbml0aW9uEg0KBXRpdGxlGAEgASgJEioKCWluc3RhbmNlcxgCIAMoCzIXLm9saXZldGluLmFwaS52MS5FbnRpdHkSGgoSdXNlZF9vbl9kYXNoYm9hcmRzGAMgAygJIjQKEEdldEVudGl0eVJlcXVlc3QSEgoKdW5pcXVlX2tleRgBIAEoCRIMCgR0eXBlGAIgASgJIjUKFFJlc3RhcnRBY3Rpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCTLoEgoST2xpdmVUaW5BcGlTZXJ2aWNlEl0KDEdldERhc2hib2FyZBIkLm9saXZldGluLmFwaS52MS5HZXREYXNoYm9hcmRSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlc3BvbnNlIgASWgoLU3RhcnRBY3Rpb24SIy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVzcG9uc2UiABJvChJTdGFydEFjdGlvbkFuZFdhaXQSKi5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBorLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXNwb25zZSIAEmkKEFN0YXJ0QWN0aW9uQnlHZXQSKC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldFJlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlIgASfgoXU3RhcnRBY3Rpb25CeUdldEFuZFdhaXQSLy5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXF1ZXN0GjAub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2UiABJeCg1SZXN0YXJ0QWN0aW9uEiUub2xpdmV0aW4uYXBpLnYxLlJlc3RhcnRBY3Rpb25SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uUmVzcG9uc2UiABJXCgpLaWxsQWN0aW9uEiIub2xpdmV0aW4uYXBpLnYxLktpbGxBY3Rpb25SZXF1ZXN0GiMub2xpdmV0aW4uYXBpLnYxLktpbGxBY3Rpb25SZXNwb25zZSIAEmYKD0V4ZWN1dGlvblN0YXR1cxInLm9saXZldGluLmFwaS52MS5FeGVjdXRpb25TdGF0dXNSZXF1ZXN0Gigub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1Jlc3BvbnNlIgASTgoHR2V0TG9ncxIfLm9saXZldGluLmFwaS52MS5HZXRMb2dzUmVxdWVzdBogLm9saXZldGluLmFwaS52MS5HZXRMb2dzUmVzcG9uc2UiABJgCg1HZXRBY3Rpb25Mb2dzEiUub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkxvZ3NSZXF1ZXN0GiYub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkxvZ3NSZXNwb25zZSIAEnUKFFZhbGlkYXRlQXJndW1lbnRUeXBlEiwub2xpdmV0aW4uYXBpLnYxLlZhbGlkYXRlQXJndW1lbnRUeXBlUmVxdWVzdBotLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlc3BvbnNlIgASSwoGV2hvQW1JEh4ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuV2hvQW1JUmVzcG9uc2UiABJUCglTb3NSZXBvcnQSIS5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVxdWVzdBoiLm9saXZldGluLmFwaS52MS5Tb3NSZXBvcnRSZXNwb25zZSIAElEKCER1bXBWYXJzEiAub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVxdWVzdBohLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1Jlc3BvbnNlIgASeAoVRHVtcFB1YmxpY0lkQWN0aW9uTWFwEi0ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlcXVlc3QaLi5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVzcG9uc2UiABJUCglHZXRSZWFkeXoSIS5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVxdWVzdBoiLm9saXZldGluLmFwaS52MS5HZXRSZWFkeXpSZXNwb25zZSIAEmMKDkxvY2FsVXNlckxvZ2luEiYub2xpdmV0aW4uYXBpLnYxLkxvY2FsVXNlckxvZ2luUmVxdWVzdBonLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlc3BvbnNlIgASXQoMUGFzc3dvcmRIYXNoEiQub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuUGFzc3dvcmRIYXNoUmVzcG9uc2UiABJLCgZMb2dvdXQSHi5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVxdWVzdBofLm9saXZldGluLmFwaS52MS5Mb2dvdXRSZXNwb25zZSIAElwKC0V2ZW50U3RyZWFtEiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50U3RyZWFtUmVxdWVzdBokLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlc3BvbnNlIgAwARJjCg5HZXREaWFnbm9zdGljcxImLm9saXZldGluLmFwaS52MS5HZXREaWFnbm9zdGljc1JlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXNwb25zZSIAEkUKBEluaXQSHC5vbGl2ZXRpbi5hcGkudjEuSW5pdFJlcXVlc3QaHS5vbGl2ZXRpbi5hcGkudjEuSW5pdFJlc3BvbnNlIgASaQoQR2V0QWN0aW9uQmluZGluZxIoLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25CaW5kaW5nUmVxdWVzdBopLm9saXZldGluLmFwaS52MS5HZXRBY3Rpb25CaW5kaW5nUmVzcG9uc2UiABJaCgtHZXRFbnRpdGllcxIjLm9saXZldGluLmFwaS52MS5HZXRFbnRpdGllc1JlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXNwb25zZSIAEkkKCUdldEVudGl0eRIhLm9saXZldGluLmFwaS52MS5HZXRFbnRpdHlSZXF1ZXN0Ghcub2xpdmV0aW4uYXBpLnYxLkVudGl0eSIAQjhaNmdpdGh1Yi5jb20vT2xpdmVUaW4vT2xpdmVUaW4vZ2VuL29saXZldGluL2FwaS92MTthcGl2MWIGcHJvdG8z"); /** * Describes the message olivetin.api.v1.Action. diff --git a/frontend/resources/vue/ActionButton.vue b/frontend/resources/vue/ActionButton.vue index dfd4ab0..9d3e8ea 100644 --- a/frontend/resources/vue/ActionButton.vue +++ b/frontend/resources/vue/ActionButton.vue @@ -279,11 +279,18 @@ async function startAction(actionArgs) { function onLogEntryChanged(logEntry) { if (logEntry.executionFinished) { onExecutionFinished(logEntry) + } else if (logEntry.queued && !logEntry.executionStarted) { + onExecutionQueued(logEntry) } else { onExecutionStarted(logEntry) } } +function onExecutionQueued(_logEntry) { + isDisabled.value = true + updateDom('action-queued', '[Queued]') +} + function onExecutionStarted(logEntry) { if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) { router.push(`/logs/${logEntry.executionTrackingId}`) @@ -440,6 +447,12 @@ defineExpose({ color: #721c24; } + .action-button button.action-queued { + background: #fff3cd !important; + border-color: #ffeaa7; + color: #856404; + } + .action-button button.action-nonzero-exit { background: #f8d7da !important; border-color: #f5c6cb; diff --git a/proto/olivetin/api/v1/olivetin.proto b/proto/olivetin/api/v1/olivetin.proto index 4f90785..75421c5 100644 --- a/proto/olivetin/api/v1/olivetin.proto +++ b/proto/olivetin/api/v1/olivetin.proto @@ -157,6 +157,8 @@ message LogEntry { bool can_kill = 18; string datetime_rate_limit_expires = 19; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" string binding_id = 20; // Binding ID for matching rate limits to action buttons + bool queued = 21; + string queued_for_group = 22; } message GetLogsResponse { diff --git a/service/gen/olivetin/api/v1/olivetin.pb.go b/service/gen/olivetin/api/v1/olivetin.pb.go index fc7998c..d81402a 100644 --- a/service/gen/olivetin/api/v1/olivetin.pb.go +++ b/service/gen/olivetin/api/v1/olivetin.pb.go @@ -1305,6 +1305,8 @@ type LogEntry struct { CanKill bool `protobuf:"varint,18,opt,name=can_kill,json=canKill,proto3" json:"can_kill,omitempty"` DatetimeRateLimitExpires string `protobuf:"bytes,19,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" BindingId string `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` // Binding ID for matching rate limits to action buttons + Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"` + QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1465,6 +1467,20 @@ func (x *LogEntry) GetBindingId() string { return "" } +func (x *LogEntry) GetQueued() bool { + if x != nil { + return x.Queued + } + return false +} + +func (x *LogEntry) GetQueuedForGroup() string { + if x != nil { + return x.QueuedForGroup + } + return "" +} + type GetLogsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` @@ -4183,7 +4199,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\x12\x1f\n" + "\vdate_filter\x18\x02 \x01(\tR\n" + "dateFilter\x12\x1b\n" + - "\tpage_size\x18\x03 \x01(\x03R\bpageSize\"\x89\x05\n" + + "\tpage_size\x18\x03 \x01(\x03R\bpageSize\"\xcb\x05\n" + "\bLogEntry\x12)\n" + "\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" + "\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" + @@ -4206,7 +4222,9 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\bcan_kill\x18\x12 \x01(\bR\acanKill\x12=\n" + "\x1bdatetime_rate_limit_expires\x18\x13 \x01(\tR\x18datetimeRateLimitExpires\x12\x1d\n" + "\n" + - "binding_id\x18\x14 \x01(\tR\tbindingId\"\xca\x01\n" + + "binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" + + "\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" + + "\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\"\xca\x01\n" + "\x0fGetLogsResponse\x12-\n" + "\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" + "\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" + diff --git a/service/internal/api/api.go b/service/internal/api/api.go index ebbc798..9ddf81f 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -378,6 +378,8 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry Output: logEntry.Output, TimedOut: logEntry.TimedOut, Blocked: logEntry.Blocked, + Queued: logEntry.Queued, + QueuedForGroup: logEntry.QueuedForGroup, ExitCode: logEntry.ExitCode, Tags: logEntry.Tags, ExecutionTrackingId: logEntry.ExecutionTrackingID, diff --git a/service/internal/config/config.go b/service/internal/config/config.go index b118afa..e692184 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -33,6 +33,13 @@ type Action struct { PopupOnStart string `koanf:"popupOnStart"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` EnabledExpression string `koanf:"enabledExpression"` + Groups []string `koanf:"groups"` +} + +// ActionGroup defines shared limits and metadata for a set of actions. +type ActionGroup struct { + MaxConcurrent int `koanf:"maxConcurrent"` + Icon string `koanf:"icon"` } // ActionArgument objects appear on Actions. @@ -134,6 +141,7 @@ type Config struct { LogLevel string `koanf:"logLevel"` LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"` LogHistoryPageSize int64 `koanf:"logHistoryPageSize"` + ActionGroups map[string]*ActionGroup `koanf:"actionGroups"` Actions []*Action `koanf:"actions"` Entities []*EntityFile `koanf:"entities"` Dashboards []*DashboardComponent `koanf:"dashboards"` diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index d22cc90..cd0d6ea 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -25,6 +25,8 @@ func (cfg *Config) Sanitize() { cfg.Actions[idx].sanitize(cfg) } + cfg.sanitizeActionGroupReferences() + cfg.sanitizeDashboardsForInlineActions() if err := cfg.validateReservedActionArgumentNames(); err != nil { @@ -181,11 +183,51 @@ func (action *Action) sanitize(cfg *Config) { action.MaxConcurrent = 1 } + action.Groups = dedupeStrings(action.Groups) + for idx := range action.Arguments { action.Arguments[idx].sanitize() } } +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + + for _, value := range values { + out = appendUniqueString(out, seen, value) + } + + return out +} + +func appendUniqueString(out []string, seen map[string]struct{}, value string) []string { + if value == "" { + return out + } + + if _, found := seen[value]; found { + return out + } + + seen[value] = struct{}{} + + return append(out, value) +} + +func (cfg *Config) sanitizeActionGroupReferences() { + for _, action := range cfg.Actions { + for _, groupName := range action.Groups { + if _, found := cfg.ActionGroups[groupName]; !found { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "groupName": groupName, + }).Warn("Action references unknown action group") + } + } + } +} + func (cfg *Config) sanitizeAuthRequireGuestsToLogin() { if cfg.AuthRequireGuestsToLogin { log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false") diff --git a/service/internal/config/sanitize_test.go b/service/internal/config/sanitize_test.go index 036d710..71ed379 100644 --- a/service/internal/config/sanitize_test.go +++ b/service/internal/config/sanitize_test.go @@ -107,6 +107,24 @@ func TestValidateReservedActionArgumentNames(t *testing.T) { assert.Contains(t, err.Error(), `action "Reserved arg" argument "ot_custom" uses reserved prefix "ot_"`) } +func TestSanitizeActionGroupsDedupesGroupNames(t *testing.T) { + c := DefaultConfig() + c.ActionGroups = map[string]*ActionGroup{ + "unity": {MaxConcurrent: 1}, + } + c.Actions = append(c.Actions, &Action{ + Title: "Build", + Shell: "true", + Groups: []string{"unity", "unity", ""}, + }) + + c.Sanitize() + + action := c.findAction("Build") + require.NotNil(t, action) + assert.Equal(t, []string{"unity"}, action.Groups) +} + func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) { c := DefaultConfig() c.Actions = append(c.Actions, &Action{ diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index fbcaa68..fabe915 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -71,6 +71,9 @@ type Executor struct { listeners []listener chainOfCommand []executorStepFunc + + groupQueue []*queuedExecution + groupQueueMu sync.Mutex } // ExecutionRequest is a request to execute an action. It's passed to an @@ -84,11 +87,12 @@ type ExecutionRequest struct { AuthenticatedUser *authpublic.AuthenticatedUser TriggerDepth int - logEntry *InternalLogEntry - finalParsedCommand string - execArgs []string - useDirectExec bool - executor *Executor + logEntry *InternalLogEntry + finalParsedCommand string + execArgs []string + useDirectExec bool + executor *Executor + skipRequestRegistration bool } // InternalLogEntry objects are created by an Executor, and represent the final @@ -101,6 +105,8 @@ type InternalLogEntry struct { Output string TimedOut bool Blocked bool + Queued bool + QueuedForGroup string ExitCode int32 Tags []string ExecutionStarted bool @@ -496,6 +502,26 @@ func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) { // ExecRequest processes an ExecutionRequest func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) { + e.initializeExecRequest(req) + + log.Tracef("executor.ExecRequest(): %v", req) + + e.SetLog(req.TrackingID, req.logEntry) + + wg := new(sync.WaitGroup) + wg.Add(1) + + go func() { + queued := e.execChain(req, wg) + if !queued { + wg.Done() + } + }() + + return wg, req.TrackingID +} + +func (e *Executor) initializeExecRequest(req *ExecutionRequest) { if req.AuthenticatedUser == nil { req.AuthenticatedUser = auth.UserGuest(req.Cfg) } @@ -519,41 +545,54 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) req.TrackingID = uuid.NewString() } - // Update the log entry with the final tracking ID req.logEntry.ExecutionTrackingID = req.TrackingID - - log.Tracef("executor.ExecRequest(): %v", req) - - e.SetLog(req.TrackingID, req.logEntry) - - wg := new(sync.WaitGroup) - wg.Add(1) - - go func() { - e.execChain(req) - defer wg.Done() - }() - - return wg, req.TrackingID } -func (e *Executor) execChain(req *ExecutionRequest) { - for _, step := range e.chainOfCommand { +func (e *Executor) execChain(req *ExecutionRequest, wg *sync.WaitGroup) bool { + if !req.skipRequestRegistration { + finished, queued := e.registerOrQueueRequest(req, wg) + if finished || queued { + return queued + } + } + + e.runExecutionSteps(req) + e.finishExecChain(req) + + return false +} + +func (e *Executor) registerOrQueueRequest(req *ExecutionRequest, wg *sync.WaitGroup) (finished bool, queued bool) { + if !stepRequestAction(req) { + e.finishExecChain(req) + return true, false + } + + if !actionNeedsGroupLimit(req) || e.groupsHaveCapacityForActive(req) { + return false, false + } + + e.queueRequest(req, wg) + + return false, true +} + +func (e *Executor) runExecutionSteps(req *ExecutionRequest) { + for _, step := range e.chainOfCommand[1:] { if !step(req) { break } } +} - // Ensure DatetimeFinished is set even if execution was blocked early +func (e *Executor) finishExecChain(req *ExecutionRequest) { if req.logEntry.DatetimeFinished.IsZero() { req.logEntry.DatetimeFinished = time.Now() } req.logEntry.ExecutionFinished = true - - // This isn't a step, because we want to notify all listeners, irrespective - // of how many steps were actually executed. notifyListenersFinished(req) + e.drainGroupQueue() } func getConcurrentCount(req *ExecutionRequest) int { diff --git a/service/internal/executor/group_concurrency.go b/service/internal/executor/group_concurrency.go new file mode 100644 index 0000000..b1a09b7 --- /dev/null +++ b/service/internal/executor/group_concurrency.go @@ -0,0 +1,182 @@ +package executor + +import ( + "fmt" + "slices" + "sync" + + config "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type groupLimit struct { + name string + maxConcurrent int +} + +type queuedExecution struct { + req *ExecutionRequest + wg *sync.WaitGroup +} + +func actionGroupLimits(req *ExecutionRequest) []groupLimit { + if !hasActionGroupContext(req) { + return nil + } + + limits := make([]groupLimit, 0, len(req.Binding.Action.Groups)) + + for _, groupName := range req.Binding.Action.Groups { + if limit, ok := groupLimitFromConfig(req.Cfg, groupName); ok { + limits = append(limits, limit) + } + } + + return limits +} + +func hasActionGroupContext(req *ExecutionRequest) bool { + return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Cfg != nil +} + +func groupLimitFromConfig(cfg *config.Config, groupName string) (groupLimit, bool) { + group, found := cfg.ActionGroups[groupName] + if !found || group == nil || group.MaxConcurrent < 1 { + return groupLimit{}, false + } + + return groupLimit{name: groupName, maxConcurrent: group.MaxConcurrent}, true +} + +func actionNeedsGroupLimit(req *ExecutionRequest) bool { + return len(actionGroupLimits(req)) > 0 +} + +func actionInGroup(action *config.Action, groupName string) bool { + if action == nil { + return false + } + + return slices.Contains(action.Groups, groupName) +} + +func (e *Executor) countActiveInGroup(groupName string) int { + count := 0 + + e.logmutex.RLock() + defer e.logmutex.RUnlock() + + for _, logEntry := range e.logs { + if logEntryIsActiveInGroup(logEntry, groupName) { + count++ + } + } + + return count +} + +func logEntryIsActiveInGroup(logEntry *InternalLogEntry, groupName string) bool { + if inactiveLogEntry(logEntry) { + return false + } + + return actionInGroup(logEntry.Binding.Action, groupName) +} + +func inactiveLogEntry(logEntry *InternalLogEntry) bool { + if logEntry == nil { + return true + } + + return logEntryIsInactive(logEntry) +} + +func logEntryIsInactive(logEntry *InternalLogEntry) bool { + if logEntry.ExecutionFinished || logEntry.Queued { + return true + } + + return logEntry.Binding == nil || logEntry.Binding.Action == nil +} + +func (e *Executor) groupsHaveCapacityForActive(req *ExecutionRequest) bool { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroup(limit.name) >= (limit.maxConcurrent + 1) { + return false + } + } + + return true +} + +func (e *Executor) groupsHaveCapacityForQueued(req *ExecutionRequest) bool { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroup(limit.name) >= limit.maxConcurrent { + return false + } + } + + return true +} + +func firstFullGroupName(e *Executor, req *ExecutionRequest) string { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroup(limit.name) >= (limit.maxConcurrent + 1) { + return limit.name + } + } + + return "" +} + +func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) { + groupName := firstFullGroupName(e, req) + + req.logEntry.Queued = true + req.logEntry.QueuedForGroup = groupName + req.logEntry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName) + + log.WithFields(log.Fields{ + "actionTitle": req.logEntry.ActionTitle, + "groupName": groupName, + }).Infof("Action queued due to action group concurrency limit") + + e.groupQueueMu.Lock() + e.groupQueue = append(e.groupQueue, &queuedExecution{req: req, wg: wg}) + e.groupQueueMu.Unlock() +} + +func (e *Executor) drainGroupQueue() { + e.groupQueueMu.Lock() + + if len(e.groupQueue) == 0 { + e.groupQueueMu.Unlock() + return + } + + next := e.groupQueue[0] + if !e.groupsHaveCapacityForQueued(next.req) { + e.groupQueueMu.Unlock() + return + } + + e.groupQueue = e.groupQueue[1:] + e.groupQueueMu.Unlock() + + go e.runDequeuedExecution(next) +} + +func (e *Executor) runDequeuedExecution(queued *queuedExecution) { + req := queued.req + + e.logmutex.Lock() + req.logEntry.Queued = false + req.logEntry.QueuedForGroup = "" + e.logmutex.Unlock() + + req.skipRequestRegistration = true + + e.runExecutionSteps(req) + e.finishExecChain(req) + queued.wg.Done() +} diff --git a/service/internal/executor/group_concurrency_test.go b/service/internal/executor/group_concurrency_test.go new file mode 100644 index 0000000..11591a5 --- /dev/null +++ b/service/internal/executor/group_concurrency_test.go @@ -0,0 +1,277 @@ +package executor + +import ( + "sync" + "testing" + "time" + + "github.com/OliveTin/OliveTin/internal/auth" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testGroupExecutor(actions []*config.Action, groups map[string]*config.ActionGroup) (*Executor, *config.Config) { + cfg := config.DefaultConfig() + cfg.ActionGroups = groups + cfg.Actions = actions + cfg.Sanitize() + + e := DefaultExecutor(cfg) + e.RebuildActionMap() + + return e, cfg +} + +func TestGroupConcurrencyQueuesSecondAction(t *testing.T) { + t.Parallel() + + slowAction := &config.Action{ + Title: "Unity Job 1", + Shell: "sleep 2", + Groups: []string{"unity"}, + } + fastAction := &config.Action{ + Title: "Unity Job 2", + Shell: "echo queued-run", + Groups: []string{"unity"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{slowAction, fastAction}, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1}, + }, + ) + + binding1 := e.FindBindingWithNoEntity(slowAction) + binding2 := e.FindBindingWithNoEntity(fastAction) + require.NotNil(t, binding1) + require.NotNil(t, binding2) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: binding1, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding2, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + logEntry, ok := e.GetLog(tracking2) + return ok && logEntry.Queued + }, time.Second, 10*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + logEntry, ok := e.GetLog(tracking2) + require.True(t, ok) + assert.False(t, logEntry.Queued) + assert.False(t, logEntry.Blocked) + assert.Equal(t, int32(0), logEntry.ExitCode) + assert.Contains(t, logEntry.Output, "queued-run") +} + +func TestDifferentGroupsRunConcurrently(t *testing.T) { + t.Parallel() + + actionA := &config.Action{ + Title: "Group A Job", + Shell: "sleep 1", + Groups: []string{"groupA"}, + } + actionB := &config.Action{ + Title: "Group B Job", + Shell: "echo group-b", + Groups: []string{"groupB"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{actionA, actionB}, + map[string]*config.ActionGroup{ + "groupA": {MaxConcurrent: 1}, + "groupB": {MaxConcurrent: 1}, + }, + ) + + wg1, _ := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(actionA), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + time.Sleep(50 * time.Millisecond) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(actionB), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + logEntry, ok := e.GetLog(tracking2) + return ok && logEntry.ExecutionFinished && !logEntry.Queued + }, 2*time.Second, 20*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + logEntry, ok := e.GetLog(tracking2) + require.True(t, ok) + assert.Contains(t, logEntry.Output, "group-b") +} + +func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Single binding", + Shell: "sleep 1", + MaxConcurrent: 1, + } + + e, cfg := testGroupExecutor([]*config.Action{action}, nil) + binding := e.FindBindingWithNoEntity(action) + + wg1, _ := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + time.Sleep(50 * time.Millisecond) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + wg1.Wait() + wg2.Wait() + + logEntry, ok := e.GetLog(tracking2) + require.True(t, ok) + assert.True(t, logEntry.Blocked) + assert.False(t, logEntry.Queued) +} + +func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) { + t.Helper() + + require.Eventually(t, func() bool { + logEntry, ok := e.GetLog(trackingID) + return ok && logEntry.ExecutionStarted + }, 2*time.Second, 10*time.Millisecond) +} + +func assertWaitGroupPending(t *testing.T, wg *sync.WaitGroup) { + t.Helper() + + done := make(chan struct{}) + + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + t.Fatal("wait group completed before queued execution finished") + case <-time.After(100 * time.Millisecond): + } +} + +func assertWaitGroupCompletes(t *testing.T, wg *sync.WaitGroup) { + t.Helper() + + done := make(chan struct{}) + + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("wait group did not complete after queue drained") + } +} + +func TestStartActionAndWaitWaitsForQueuedExecution(t *testing.T) { + t.Parallel() + + first := &config.Action{ + Title: "Hold group", + Shell: "sleep 1", + Groups: []string{"unity"}, + } + second := &config.Action{ + Title: "Wait in queue", + Shell: "echo waited", + Groups: []string{"unity"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{first, second}, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1}, + }, + ) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(first), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(second), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + assertWaitGroupPending(t, wg2) + + wg1.Wait() + + assertWaitGroupCompletes(t, wg2) + + logEntry, ok := e.GetLog(tracking2) + require.True(t, ok) + assert.Contains(t, logEntry.Output, "waited") +} + +func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Unknown group action", + Shell: "echo ok", + Groups: []string{"missing"}, + } + + e, cfg := testGroupExecutor([]*config.Action{action}, map[string]*config.ActionGroup{}) + wg, tracking := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(action), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + wg.Wait() + + logEntry, ok := e.GetLog(tracking) + require.True(t, ok) + assert.False(t, logEntry.Queued) + assert.Equal(t, int32(0), logEntry.ExitCode) +} diff --git a/specs/action-group-concurrency.md b/specs/action-group-concurrency.md new file mode 100644 index 0000000..e45cfbe --- /dev/null +++ b/specs/action-group-concurrency.md @@ -0,0 +1,19 @@ +# Action group concurrency + +Actions may belong to one or more named groups. Each group may define a maximum number of concurrent executions shared across all actions in that group. + +When a user or trigger starts an action that belongs to a group, OliveTin counts how many executions for that group are currently active. Active means the execution has been requested but not yet finished, and is not waiting in a queue. + +If every configured group for that action has spare capacity, the execution proceeds through the normal execution pipeline. + +If any configured group is at capacity, the new execution is queued instead of rejected. The request receives a tracking identifier immediately. The log entry shows a queued status until the execution actually starts. + +Queued executions run in first-in-first-out order per OliveTin instance. When an active execution in a group finishes, OliveTin attempts to start the oldest queued execution that belongs to that group, provided all groups for that queued action now have spare capacity. + +An action may belong to multiple groups. In that case, all group limits must be satisfied before the action starts or leaves the queue. + +Per-action concurrency limits apply only to executions of the same action binding. When a per-action limit is exceeded, the request is blocked immediately and is not queued. + +Action group concurrency limits do not survive a process restart. Queued executions that have not started are discarded when OliveTin stops. + +If an action references a group name that is not defined in configuration, OliveTin logs a warning and does not apply a group limit for that name. From 0e8f7c7be3b3bf616a012b5b6c449e95a180b551 Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 16 Jun 2026 11:02:37 +0100 Subject: [PATCH 2/3] chore: coderabbit suggestions --- frontend/resources/vue/ActionButton.vue | 1 + service/internal/config/sanitize.go | 29 +- service/internal/executor/executor.go | 272 +++++++++++++----- .../internal/executor/group_concurrency.go | 41 ++- .../executor/group_concurrency_test.go | 50 ++-- 5 files changed, 274 insertions(+), 119 deletions(-) diff --git a/frontend/resources/vue/ActionButton.vue b/frontend/resources/vue/ActionButton.vue index 9d3e8ea..4134e6d 100644 --- a/frontend/resources/vue/ActionButton.vue +++ b/frontend/resources/vue/ActionButton.vue @@ -297,6 +297,7 @@ function onExecutionStarted(logEntry) { } isDisabled.value = true + updateDom(null, title.value) } function onExecutionFinished(logEntry) { diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index cd0d6ea..44f5fd7 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -25,10 +25,10 @@ func (cfg *Config) Sanitize() { cfg.Actions[idx].sanitize(cfg) } - cfg.sanitizeActionGroupReferences() - cfg.sanitizeDashboardsForInlineActions() + cfg.sanitizeActionGroupReferences() + if err := cfg.validateReservedActionArgumentNames(); err != nil { log.Fatalf("%v", err) } @@ -218,16 +218,29 @@ func appendUniqueString(out []string, seen map[string]struct{}, value string) [] func (cfg *Config) sanitizeActionGroupReferences() { for _, action := range cfg.Actions { for _, groupName := range action.Groups { - if _, found := cfg.ActionGroups[groupName]; !found { - log.WithFields(log.Fields{ - "actionTitle": action.Title, - "groupName": groupName, - }).Warn("Action references unknown action group") - } + cfg.warnInvalidActionGroupReference(action, groupName) } } } +func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) { + group, found := cfg.ActionGroups[groupName] + if !found { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "groupName": groupName, + }).Warn("Action references unknown action group") + return + } + + if group == nil || group.MaxConcurrent < 1 { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "groupName": groupName, + }).Warn("Action references action group that will not be enforced at runtime") + } +} + func (cfg *Config) sanitizeAuthRequireGuestsToLogin() { if cfg.AuthRequireGuestsToLogin { log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false") diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index fabe915..72fbedd 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -95,6 +95,48 @@ type ExecutionRequest struct { skipRequestRegistration bool } +func (req *ExecutionRequest) mutateLogEntry(mutator func(*InternalLogEntry)) { + if req.executor == nil { + mutator(req.logEntry) + return + } + + req.executor.logmutex.Lock() + defer req.executor.logmutex.Unlock() + + mutator(req.logEntry) +} + +// LogEntrySnapshot is a copy of selected log entry fields for race-safe reads. +type LogEntrySnapshot struct { + Queued bool + Blocked bool + ExecutionStarted bool + ExecutionFinished bool + ExitCode int32 + Output string +} + +// SnapshotLog returns a copy of selected log entry fields under read lock. +func (e *Executor) SnapshotLog(trackingID string) (LogEntrySnapshot, bool) { + e.logmutex.RLock() + defer e.logmutex.RUnlock() + + entry, found := e.logs[trackingID] + if !found { + return LogEntrySnapshot{}, false + } + + return LogEntrySnapshot{ + Queued: entry.Queued, + Blocked: entry.Blocked, + ExecutionStarted: entry.ExecutionStarted, + ExecutionFinished: entry.ExecutionFinished, + ExitCode: entry.ExitCode, + Output: entry.Output, + }, true +} + // InternalLogEntry objects are created by an Executor, and represent the final // state of execution (even if the command is not executed). It's designed to be // easily serializable. @@ -375,7 +417,7 @@ func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry { // shouldCountExecution checks if a log entry should be counted for rate limiting. func shouldCountExecution(logEntry *InternalLogEntry, windowStart time.Time) bool { - return !logEntry.Blocked && logEntry.DatetimeStarted.After(windowStart) + return !logEntry.Blocked && !logEntry.Queued && logEntry.DatetimeStarted.After(windowStart) } // updateOldestExecution updates the oldest execution time if this entry is older. @@ -489,24 +531,30 @@ func (e *Executor) GetTimeUntilAvailable(binding *ActionBinding) int64 { return maxExpiryTime.Unix() } -func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) { +func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) string { e.logmutex.Lock() + defer e.logmutex.Unlock() + + if _, found := e.logs[trackingID]; found || !isValidTrackingID(trackingID) { + trackingID = uuid.NewString() + entry.ExecutionTrackingID = trackingID + } entry.Index = int64(len(e.logsTrackingIdsByDate)) e.logs[trackingID] = entry e.logsTrackingIdsByDate = append(e.logsTrackingIdsByDate, trackingID) - e.logmutex.Unlock() + return trackingID } // ExecRequest processes an ExecutionRequest func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) { e.initializeExecRequest(req) - log.Tracef("executor.ExecRequest(): %v", req) + log.Tracef("executor.ExecRequest(): trackingID=%s bindingID=%s", req.TrackingID, bindingIDForTrace(req)) - e.SetLog(req.TrackingID, req.logEntry) + req.TrackingID = e.SetLog(req.TrackingID, req.logEntry) wg := new(sync.WaitGroup) wg.Add(1) @@ -540,12 +588,15 @@ func (e *Executor) initializeExecRequest(req *ExecutionRequest) { Username: req.AuthenticatedUser.Username, } - _, isDuplicate := e.GetLog(req.TrackingID) - if isDuplicate || !isValidTrackingID(req.TrackingID) { - req.TrackingID = uuid.NewString() + req.logEntry.ExecutionTrackingID = req.TrackingID +} + +func bindingIDForTrace(req *ExecutionRequest) string { + if req.Binding == nil { + return "" } - req.logEntry.ExecutionTrackingID = req.TrackingID + return req.Binding.ID } func (e *Executor) execChain(req *ExecutionRequest, wg *sync.WaitGroup) bool { @@ -572,7 +623,17 @@ func (e *Executor) registerOrQueueRequest(req *ExecutionRequest, wg *sync.WaitGr return false, false } + return e.queueRequestAfterACL(req, wg) +} + +func (e *Executor) queueRequestAfterACL(req *ExecutionRequest, wg *sync.WaitGroup) (finished bool, queued bool) { + if !stepACLCheck(req) { + e.finishExecChain(req) + return true, false + } + e.queueRequest(req, wg) + notifyListenersStarted(req) return false, true } @@ -586,11 +647,14 @@ func (e *Executor) runExecutionSteps(req *ExecutionRequest) { } func (e *Executor) finishExecChain(req *ExecutionRequest) { - if req.logEntry.DatetimeFinished.IsZero() { - req.logEntry.DatetimeFinished = time.Now() - } + req.mutateLogEntry(func(entry *InternalLogEntry) { + if entry.DatetimeFinished.IsZero() { + entry.DatetimeFinished = time.Now() + } + + entry.ExecutionFinished = true + }) - req.logEntry.ExecutionFinished = true notifyListenersFinished(req) e.drainGroupQueue() } @@ -599,9 +663,10 @@ func getConcurrentCount(req *ExecutionRequest) int { concurrentCount := 0 req.executor.logmutex.RLock() + logs := req.executor.LogsByBindingId[req.Binding.ID] - for _, log := range req.executor.GetLogsByBindingId(req.Binding.ID) { - if !log.ExecutionFinished { + for _, log := range logs { + if !log.ExecutionFinished && !log.Queued { concurrentCount += 1 } } @@ -622,8 +687,10 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool { "maxConcurrent": req.Binding.Action.MaxConcurrent, }).Warnf("Blocked from executing due to concurrency limit") - req.logEntry.Output = "Blocked from executing due to concurrency limit" - req.logEntry.Blocked = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "Blocked from executing due to concurrency limit" + entry.Blocked = true + }) return false } @@ -642,24 +709,35 @@ func parseDuration(rate config.RateSpec) time.Duration { return duration } -//gocyclo:ignore -func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int { - executions := -1 // Because we will find ourself when checking execution logs - - duration := parseDuration(rate) - - then := time.Now().Add(-duration) - - currentEntityPrefix := "" +func entityPrefixForRequest(req *ExecutionRequest) string { if req.Binding != nil && req.Binding.Entity != nil { - currentEntityPrefix = req.Binding.Entity.UniqueKey + return req.Binding.Entity.UniqueKey } - for _, logEntry := range req.executor.GetLogsByBindingId(req.Binding.ID) { - if logEntry.EntityPrefix != currentEntityPrefix { - continue - } - if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked { + return "" +} + +func rateExecutionMatchesScope(logEntry *InternalLogEntry, req *ExecutionRequest, entityPrefix string) bool { + if logEntry.EntityPrefix != entityPrefix { + return false + } + + return !logEntry.Queued && logEntry.ExecutionTrackingID != req.TrackingID +} + +func logEntryStartedInWindow(logEntry *InternalLogEntry, windowStart time.Time) bool { + return logEntry.DatetimeStarted.After(windowStart) && !logEntry.Blocked +} + +func rateExecutionCountsForRate(logEntry *InternalLogEntry, req *ExecutionRequest, entityPrefix string, windowStart time.Time) bool { + return rateExecutionMatchesScope(logEntry, req, entityPrefix) && logEntryStartedInWindow(logEntry, windowStart) +} + +func countRateExecutions(logs []*InternalLogEntry, req *ExecutionRequest, entityPrefix string, windowStart time.Time) int { + executions := 0 + + for _, logEntry := range logs { + if rateExecutionCountsForRate(logEntry, req, entityPrefix, windowStart) { executions += 1 } } @@ -667,6 +745,18 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int { return executions } +func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int { + duration := parseDuration(rate) + then := time.Now().Add(-duration) + + req.executor.logmutex.RLock() + logs := req.executor.LogsByBindingId[req.Binding.ID] + executions := countRateExecutions(logs, req, entityPrefixForRequest(req), then) + req.executor.logmutex.RUnlock() + + return executions +} + func stepRateCheck(req *ExecutionRequest) bool { for _, rate := range req.Binding.Action.MaxRate { executions := getExecutionsCount(rate, req) @@ -679,8 +769,10 @@ func stepRateCheck(req *ExecutionRequest) bool { "duration": rate.Duration, }).Infof("Blocked from executing due to rate limit") - req.logEntry.Output = "Blocked from executing due to rate limit" - req.logEntry.Blocked = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "Blocked from executing due to rate limit" + entry.Blocked = true + }) return false } } @@ -692,8 +784,10 @@ func stepACLCheck(req *ExecutionRequest) bool { canExec := acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Binding.Action) if !canExec { - req.logEntry.Output = "ACL check failed. Blocked from executing." - req.logEntry.Blocked = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "ACL check failed. Blocked from executing." + entry.Blocked = true + }) log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -831,7 +925,9 @@ func hasExec(req *ExecutionRequest) bool { } func fail(req *ExecutionRequest, err error) bool { - req.logEntry.Output = err.Error() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = err.Error() + }) log.Warn(err.Error()) return false } @@ -865,14 +961,16 @@ func stepRequestActionHasBinding(req *ExecutionRequest) bool { } func stepRequestActionPopulateLogEntry(req *ExecutionRequest) { - req.logEntry.Binding = req.Binding - req.logEntry.ActionConfigTitle = req.Binding.Action.Title - req.logEntry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity) - req.logEntry.ActionIcon = req.Binding.Action.Icon - req.logEntry.Tags = req.Tags - if req.Binding.Entity != nil { - req.logEntry.EntityPrefix = req.Binding.Entity.UniqueKey - } + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Binding = req.Binding + entry.ActionConfigTitle = req.Binding.Action.Title + entry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity) + entry.ActionIcon = req.Binding.Action.Icon + entry.Tags = req.Tags + if req.Binding.Entity != nil { + entry.EntityPrefix = req.Binding.Entity.UniqueKey + } + }) } func stepRequestActionRegisterLog(req *ExecutionRequest) { @@ -895,7 +993,9 @@ func stepLogStart(req *ExecutionRequest) bool { } func stepLogFinish(req *ExecutionRequest) bool { - req.logEntry.ExecutionFinished = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.ExecutionFinished = true + }) log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -919,10 +1019,14 @@ func notifyListenersStarted(req *ExecutionRequest) { } } -func appendErrorToStderr(err error, logEntry *InternalLogEntry) { - if err != nil { - logEntry.Output = err.Error() + "\n\n" + logEntry.Output +func appendErrorToStderr(req *ExecutionRequest, err error) { + if err == nil { + return } + + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = err.Error() + "\n\n" + entry.Output + }) } type OutputStreamer struct { @@ -965,31 +1069,41 @@ func stepExec(req *ExecutionRequest) bool { streamer := &OutputStreamer{Req: req} cmd := buildCommand(ctx, req) if cmd == nil { - req.logEntry.Output = "Cannot execute: no command arguments provided" + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "Cannot execute: no command arguments provided" + }) log.Warn("Cannot execute: no command arguments provided") return false } prepareCommand(cmd, streamer, req) runerr := cmd.Start() - req.logEntry.Process = cmd.Process + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Process = cmd.Process + }) ctx.setProcess(cmd.Process) waiterr := cmd.Wait() - req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode()) - req.logEntry.Output = streamer.String() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.ExitCode = int32(cmd.ProcessState.ExitCode()) + entry.Output = streamer.String() + }) - appendErrorToStderr(runerr, req.logEntry) - appendErrorToStderr(waiterr, req.logEntry) + appendErrorToStderr(req, runerr) + appendErrorToStderr(req, waiterr) if ctx.Err() == context.DeadlineExceeded { log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, }).Warnf("Action timed out") - req.logEntry.TimedOut = true - req.logEntry.Output += "OliveTin::timeout - this action timed out after " + fmt.Sprintf("%v", req.Binding.Action.Timeout) + " seconds. If you need more time for this action, set a longer timeout. See https://docs.olivetin.app/action_customization/timeouts.html for more help." + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.TimedOut = true + entry.Output += "OliveTin::timeout - this action timed out after " + fmt.Sprintf("%v", req.Binding.Action.Timeout) + " seconds. If you need more time for this action, set a longer timeout. See https://docs.olivetin.app/action_customization/timeouts.html for more help." + }) } - req.logEntry.DatetimeFinished = time.Now() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.DatetimeFinished = time.Now() + }) return true } @@ -1005,7 +1119,9 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque cmd.Stdout = streamer cmd.Stderr = streamer cmd.Env = buildEnv(req.Arguments) - req.logEntry.ExecutionStarted = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.ExecutionStarted = true + }) } func stepExecAfter(req *ExecutionRequest) bool { @@ -1030,24 +1146,28 @@ func stepExecAfter(req *ExecutionRequest) bool { waiterr := cmd.Wait() - req.logEntry.Output += "\n" - req.logEntry.Output += "OliveTin::shellAfterCompleted stdout\n" - req.logEntry.Output += stdout.String() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += "\n" + entry.Output += "OliveTin::shellAfterCompleted stdout\n" + entry.Output += stdout.String() + entry.Output += "OliveTin::shellAfterCompleted stderr\n" + entry.Output += stderr.String() + entry.Output += "OliveTin::shellAfterCompleted errors and summary\n" + }) - req.logEntry.Output += "OliveTin::shellAfterCompleted stderr\n" - req.logEntry.Output += stderr.String() - - req.logEntry.Output += "OliveTin::shellAfterCompleted errors and summary\n" - appendErrorToStderr(runerr, req.logEntry) - appendErrorToStderr(waiterr, req.logEntry) + appendErrorToStderr(req, runerr) + appendErrorToStderr(req, waiterr) if ctx.Err() == context.DeadlineExceeded { - req.logEntry.Output += "Your shellAfterCompleted command timed out." + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += "Your shellAfterCompleted command timed out." + }) } - req.logEntry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", cmd.ProcessState.ExitCode()) - - req.logEntry.Output += "OliveTin::shellAfterCompleted output complete\n" + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", cmd.ProcessState.ExitCode()) + entry.Output += "OliveTin::shellAfterCompleted output complete\n" + }) return true } @@ -1065,7 +1185,9 @@ func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args) if err != nil { msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n" - req.logEntry.Output += msg + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += msg + }) log.Warn(msg) return nil, nil, nil } @@ -1100,7 +1222,9 @@ func stepTrigger(req *ExecutionRequest) bool { "actionTitle": req.logEntry.ActionTitle, "depth": req.TriggerDepth, }).Warnf("Trigger action reached maximum depth of %v. Not triggering further actions.", MaxTriggerDepth) - req.logEntry.Output += fmt.Sprintf("OliveTin::trigger - this action reached maximum trigger depth of %v. Not triggering further actions.", MaxTriggerDepth) + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += fmt.Sprintf("OliveTin::trigger - this action reached maximum trigger depth of %v. Not triggering further actions.", MaxTriggerDepth) + }) return true } diff --git a/service/internal/executor/group_concurrency.go b/service/internal/executor/group_concurrency.go index b1a09b7..dc44927 100644 --- a/service/internal/executor/group_concurrency.go +++ b/service/internal/executor/group_concurrency.go @@ -61,11 +61,15 @@ func actionInGroup(action *config.Action, groupName string) bool { } func (e *Executor) countActiveInGroup(groupName string) int { - count := 0 - e.logmutex.RLock() defer e.logmutex.RUnlock() + return e.countActiveInGroupLocked(groupName) +} + +func (e *Executor) countActiveInGroupLocked(groupName string) int { + count := 0 + for _, logEntry := range e.logs { if logEntryIsActiveInGroup(logEntry, groupName) { count++ @@ -129,12 +133,25 @@ func firstFullGroupName(e *Executor, req *ExecutionRequest) string { return "" } -func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) { - groupName := firstFullGroupName(e, req) +func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroupLocked(limit.name) >= (limit.maxConcurrent + 1) { + return limit.name + } + } - req.logEntry.Queued = true - req.logEntry.QueuedForGroup = groupName - req.logEntry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName) + return "" +} + +func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) { + var groupName string + + req.mutateLogEntry(func(entry *InternalLogEntry) { + groupName = firstFullGroupNameLocked(e, req) + entry.Queued = true + entry.QueuedForGroup = groupName + entry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName) + }) log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -163,17 +180,17 @@ func (e *Executor) drainGroupQueue() { e.groupQueue = e.groupQueue[1:] e.groupQueueMu.Unlock() + next.req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Queued = false + entry.QueuedForGroup = "" + }) + go e.runDequeuedExecution(next) } func (e *Executor) runDequeuedExecution(queued *queuedExecution) { req := queued.req - e.logmutex.Lock() - req.logEntry.Queued = false - req.logEntry.QueuedForGroup = "" - e.logmutex.Unlock() - req.skipRequestRegistration = true e.runExecutionSteps(req) diff --git a/service/internal/executor/group_concurrency_test.go b/service/internal/executor/group_concurrency_test.go index 11591a5..65c5692 100644 --- a/service/internal/executor/group_concurrency_test.go +++ b/service/internal/executor/group_concurrency_test.go @@ -64,19 +64,19 @@ func TestGroupConcurrencyQueuesSecondAction(t *testing.T) { }) require.Eventually(t, func() bool { - logEntry, ok := e.GetLog(tracking2) - return ok && logEntry.Queued + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.Queued }, time.Second, 10*time.Millisecond) wg1.Wait() wg2.Wait() - logEntry, ok := e.GetLog(tracking2) + snapshot, ok := e.SnapshotLog(tracking2) require.True(t, ok) - assert.False(t, logEntry.Queued) - assert.False(t, logEntry.Blocked) - assert.Equal(t, int32(0), logEntry.ExitCode) - assert.Contains(t, logEntry.Output, "queued-run") + assert.False(t, snapshot.Queued) + assert.False(t, snapshot.Blocked) + assert.Equal(t, int32(0), snapshot.ExitCode) + assert.Contains(t, snapshot.Output, "queued-run") } func TestDifferentGroupsRunConcurrently(t *testing.T) { @@ -101,13 +101,13 @@ func TestDifferentGroupsRunConcurrently(t *testing.T) { }, ) - wg1, _ := e.ExecRequest(&ExecutionRequest{ + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ Binding: e.FindBindingWithNoEntity(actionA), Cfg: cfg, AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), }) - time.Sleep(50 * time.Millisecond) + waitUntilExecutionStarted(t, e, tracking1) wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ Binding: e.FindBindingWithNoEntity(actionB), @@ -116,16 +116,16 @@ func TestDifferentGroupsRunConcurrently(t *testing.T) { }) require.Eventually(t, func() bool { - logEntry, ok := e.GetLog(tracking2) - return ok && logEntry.ExecutionFinished && !logEntry.Queued + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.ExecutionFinished && !snapshot.Queued }, 2*time.Second, 20*time.Millisecond) wg1.Wait() wg2.Wait() - logEntry, ok := e.GetLog(tracking2) + snapshot, ok := e.SnapshotLog(tracking2) require.True(t, ok) - assert.Contains(t, logEntry.Output, "group-b") + assert.Contains(t, snapshot.Output, "group-b") } func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) { @@ -140,13 +140,13 @@ func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) { e, cfg := testGroupExecutor([]*config.Action{action}, nil) binding := e.FindBindingWithNoEntity(action) - wg1, _ := e.ExecRequest(&ExecutionRequest{ + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ Binding: binding, Cfg: cfg, AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), }) - time.Sleep(50 * time.Millisecond) + waitUntilExecutionStarted(t, e, tracking1) wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ Binding: binding, @@ -157,18 +157,18 @@ func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) { wg1.Wait() wg2.Wait() - logEntry, ok := e.GetLog(tracking2) + snapshot, ok := e.SnapshotLog(tracking2) require.True(t, ok) - assert.True(t, logEntry.Blocked) - assert.False(t, logEntry.Queued) + assert.True(t, snapshot.Blocked) + assert.False(t, snapshot.Queued) } func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) { t.Helper() require.Eventually(t, func() bool { - logEntry, ok := e.GetLog(trackingID) - return ok && logEntry.ExecutionStarted + snapshot, ok := e.SnapshotLog(trackingID) + return ok && snapshot.ExecutionStarted }, 2*time.Second, 10*time.Millisecond) } @@ -247,9 +247,9 @@ func TestStartActionAndWaitWaitsForQueuedExecution(t *testing.T) { assertWaitGroupCompletes(t, wg2) - logEntry, ok := e.GetLog(tracking2) + snapshot, ok := e.SnapshotLog(tracking2) require.True(t, ok) - assert.Contains(t, logEntry.Output, "waited") + assert.Contains(t, snapshot.Output, "waited") } func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) { @@ -270,8 +270,8 @@ func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) { wg.Wait() - logEntry, ok := e.GetLog(tracking) + snapshot, ok := e.SnapshotLog(tracking) require.True(t, ok) - assert.False(t, logEntry.Queued) - assert.Equal(t, int32(0), logEntry.ExitCode) + assert.False(t, snapshot.Queued) + assert.Equal(t, int32(0), snapshot.ExitCode) } From 8622b5b06c62d36cc472df3c68c6623a71f342bb Mon Sep 17 00:00:00 2001 From: jamesread Date: Tue, 16 Jun 2026 11:17:15 +0100 Subject: [PATCH 3/3] chore: coderabbit suggestions --- service/internal/executor/executor.go | 6 ++---- service/internal/executor/group_concurrency.go | 14 +++++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 72fbedd..10f36ab 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -587,8 +587,6 @@ func (e *Executor) initializeExecRequest(req *ExecutionRequest) { ActionIcon: "💩", Username: req.AuthenticatedUser.Username, } - - req.logEntry.ExecutionTrackingID = req.TrackingID } func bindingIDForTrace(req *ExecutionRequest) string { @@ -665,8 +663,8 @@ func getConcurrentCount(req *ExecutionRequest) int { req.executor.logmutex.RLock() logs := req.executor.LogsByBindingId[req.Binding.ID] - for _, log := range logs { - if !log.ExecutionFinished && !log.Queued { + for _, logEntry := range logs { + if !logEntry.ExecutionFinished && !logEntry.Queued { concurrentCount += 1 } } diff --git a/service/internal/executor/group_concurrency.go b/service/internal/executor/group_concurrency.go index dc44927..c6ad54b 100644 --- a/service/internal/executor/group_concurrency.go +++ b/service/internal/executor/group_concurrency.go @@ -144,6 +144,8 @@ func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string { } func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) { + e.groupQueueMu.Lock() + var groupName string req.mutateLogEntry(func(entry *InternalLogEntry) { @@ -153,14 +155,15 @@ func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) { entry.Output = fmt.Sprintf("Queued waiting for action group %q", groupName) }) + e.groupQueue = append(e.groupQueue, &queuedExecution{req: req, wg: wg}) + e.groupQueueMu.Unlock() + + e.drainGroupQueue() + log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, "groupName": groupName, }).Infof("Action queued due to action group concurrency limit") - - e.groupQueueMu.Lock() - e.groupQueue = append(e.groupQueue, &queuedExecution{req: req, wg: wg}) - e.groupQueueMu.Unlock() } func (e *Executor) drainGroupQueue() { @@ -178,13 +181,14 @@ func (e *Executor) drainGroupQueue() { } e.groupQueue = e.groupQueue[1:] - e.groupQueueMu.Unlock() next.req.mutateLogEntry(func(entry *InternalLogEntry) { entry.Queued = false entry.QueuedForGroup = "" }) + e.groupQueueMu.Unlock() + go e.runDequeuedExecution(next) }