Work Queue Service Design
Architectural design document for hummingbird-mr-service. Covers the
reasoning behind every major design choice so that future changes can be
made safely. For operational usage, see
Hummingbird MR Service.
1. Design Philosophy
Four principles shaped the service’s architecture:
Security boundary. The service sits between untrusted callers (AI agents, CI jobs, automation scripts) and trusted write operations (GitLab pushes, MR creation). All changes are inspectable structured data, not opaque blobs. Path blocklists and action validation run before any GitLab write.
Type-agnostic queue, type-specific behaviour. The processing loop, claiming, heartbeat, and lease management are generic. Type-specific logic (MR creation, CVE remediation) lives in pluggable subpackages that register deciders and executors at import time. Adding a new work item type requires no changes to the generic layer.
Single pending action. Only one action per work item at a time. After each action, the decider re-evaluates with current state. This avoids queuing actions that assume a future state which may not hold.
Phase + dimensions, not a linear state machine. The coarse lifecycle (active/completed/failed/cancelled) is separate from independent readiness signals (CI, approval, Konflux build). Dimensions can regress (force-push resets CI), which a linear state machine cannot express.
2. Architecture
Callers submit work items via REST API. The ProcessingManager drains
pending actions in per-item threads, dispatching to type-specific
executors. Currently the only executor creates GitLab MRs via the
Commits API.
flowchart LR
callers["Callers\n(CI, CLI, agents)"] --> API["REST API\n+ Auth"]
API --> DB[("PostgreSQL")]
Processing["ProcessingManager"] --> DB
Processing --> GL["GitLab\nCommits API"]
API -.-> Processing
3. Design Decisions Record
DDR-1: Phase + dimensions vs linear state machine
Decision: Replace the 12-state linear state machine with a phase + dimensions model.
Context: The initial design used a single state column:
pending → in_progress → created → mr_ci → approved → merged → post_merge_ci → building → releasing → completed. This broke because
post-creation concerns (CI, approval, Konflux build) are independent —
CI can pass before approval, approval can be revoked, force-push resets
CI but not approval.
Alternatives: (a) Composite states (ci_passed_approval_pending) —
exponential explosion. (b) Multiple boolean columns — rigid, schema
migration for each new signal.
Rationale: Phase tracks coarse lifecycle; independent dimensions in a separate table track readiness signals with variant support for multi-instance cases (per-component Konflux builds). Dimensions can regress naturally. New dimension types need no schema migration.
DDR-2: Four generic phases (active/completed/failed/cancelled)
Decision: Narrow from 6 MR-specific phases to 4 generic phases.
Context: The intermediate design had pending, active, merged,
completed, failed, closed — mixing generic concepts with
MR-specific milestones.
Alternatives: Keep MR-specific phases and add more for each type.
Rationale: Phase answers “is this item done, and how?” Type-specific
milestones (MR merged, CI passed) are tracked via dimensions and
detail-table columns. Items start as active — there is no pending
phase because the decider immediately assigns an action at creation time.
DDR-3: Dimensions in a separate table with variant support
Decision: Store dimensions in work_item_statuses table, not as
columns on work_items.
Context: Initially considered a column per dimension
(ci_status, approval_status, etc.).
Alternatives: (a) Column per dimension — schema migration for each
new one. (b) JSONB column — flexible but loses CHECK constraints and
complicates queries. (c) Separate table with composite PK
(work_item_id, dimension, variant).
Rationale: Option (c). New dimensions need no migration. Variants handle multi-instance signals (multiple Konflux components). Each work item only has rows for dimensions that apply to it. The “are all dimensions satisfied?” query is a natural GROUP BY/HAVING.
DDR-4: Decider as pure function, not state machine transitions
Decision: The decider is a pure function
decide(item) → action | None registered per type. It only determines
the next pending_action.
Context: The original design had a TRANSITIONS dict mapping
state → frozenset[state] with validate_transition().
Alternatives: (a) Transition table — rigid, can’t express “depends on which dimensions passed”. (b) Decider function — flexible, testable.
Rationale: The decider is trivially testable (given state, assert action). State transitions come from executors (they apply phase changes directly) and events (they update dimensions). The decider just picks the next action based on current state.
DDR-5: Single pending action, not a queue
Decision: One pending_action per work item at a time.
Context: Considered queuing multiple actions (“merge, then trigger Konflux build”).
Alternatives: Action queue table per work item.
Rationale: Queued actions assume future state that may not hold. If
“merge” fails, the queued “trigger Konflux build” is nonsense. After
each action, the decider re-evaluates with current state. The attempts
counter tracks consecutive failures, not total actions.
DDR-6: Claiming via leased_until (reusable)
Decision: Time-based lease (leased_until column) for work item
claiming, reusable across all phases.
Context: The original design used state = 'in_progress' as a
one-time claiming mechanism.
Alternatives: (a) claimed_by + claimed_at columns — identifies
the worker but adds complexity. (b) state = 'in_progress' — ties
claiming to phase, not reusable. (c) leased_until timestamp — simple,
reusable, self-recovering.
Rationale: Option (c). Items can be reclaimed repeatedly throughout their lifecycle. Expired leases are automatically reclaimable by other workers. The heartbeat context manager extends the lease during processing.
DDR-7: Generic + detail tables (joined table inheritance)
Decision: Split work_items into generic queue table + per-type
detail tables (mr_details, future cve_details).
Context: The initial design had all MR-specific columns on the
work_items table.
Alternatives: (a) Single table with nullable type-specific columns — sparse, doesn’t enforce type constraints. (b) JSONB payload column — flexible but loses type safety. (c) Joined table inheritance.
Rationale: Option (c). SQLAlchemy’s polymorphic_load="selectin"
automatically loads the correct subclass. New types add their own detail
table — no changes to the generic layer. Type-specific columns have
proper types and constraints.
DDR-8: Type-dispatched decider/executor registries
Decision: Registry pattern for deciders and executors. Each type
registers at import time via decider.register("mr", _mr_decide).
Context: Needed a way to dispatch to type-specific logic without the generic layer knowing about types.
Alternatives: (a) if/elif chains in the processing loop. (b) Class-based dispatch (strategy pattern). (c) Registry functions.
Rationale: Option (c). Simple, explicit, no class hierarchy needed.
The mr/__init__.py import triggers registration. New types just add
their own register() calls.
DDR-9: Structured file actions vs git patch_data
Decision: Replace opaque patch_data (BYTEA, git format-patch
output) with structured file_actions (JSONB) and commit_message.
Context: The MR service sits on the security boundary — it writes to GitLab repos on behalf of untrusted callers. HUM-851 and the AI Investigation design rules require pre-commit inspection (path blocklists, content validation).
Alternatives: (a) Keep patch_data, clone repo, apply patch, then
inspect diff — inspection happens after creating a working tree with
credentials. (b) Parse patches with unidiff library — gives diffs not
full content, still needs original files. (c) Structured file actions.
Rationale: Option (c). Inspection is a pure function over data the
service already has — no clone, no git binary, no temp dirs. Path
blocklists and action validation run before any API call. The
file_actions JSONB column is also a queryable audit record.
DDR-10: GitLab Commits API vs git subprocess
Decision: Use the GitLab Commits API for branch creation and file
commits. Delete git_ops.py.
Context: The original executor shelled out to git (clone, am,
push), requiring: git binary in the container, subprocess timeout
handling, GIT_ASKPASS credential management, temp directory lifecycle.
Alternatives: (a) Keep git subprocess with GIT_ASKPASS and timeout. (b) GitLab Commits API.
Rationale: Option (b). gitlab_sync.py in the same repo already
proves the pattern. The Commits API’s start_branch parameter creates
the branch automatically. No subprocess, no credentials in URLs, no temp
dirs. Callers have full file contents (not patches), so git am
semantics aren’t needed.
DDR-11: OIDC issuer from discovery, not hardcoded
Decision: Read the OIDC issuer from the /.well-known/openid-configuration
discovery response. Rewrite the JWKS URI to use kubernetes.default.svc.
Context: The initial implementation hardcoded
issuer = "https://kubernetes.default.svc". This failed on EaaS clusters
where --service-account-issuer is
https://oidc.op1.openshiftapps.com/.... The JWKS URI from discovery
pointed to an IP:6443 that was unreachable from pods.
Alternatives: (a) Environment variable override
(KUBE_OIDC_ISSUER). (b) Read from discovery.
Rationale: Option (b). No configuration needed — works on any cluster
automatically. The JWKS fetch uses the path from discovery but rewrites
the host to kubernetes.default.svc (always reachable on port 443). SA
bearer token authenticates both requests (OpenShift blocks anonymous
JWKS access).
DDR-12: item_type not type
Decision: Name the discriminator column item_type everywhere (DB,
ORM, API, schemas).
Context: type is a Python builtin. Using it as a function
parameter triggers ruff A002.
Alternatives: (a) Use type with alias in different contexts.
(b) Rename to item_type everywhere.
Rationale: Option (b). Clean rename, no aliases, no special casing. Consistent at every layer.
DDR-13: source field removed
Decision: Remove the source column from mr_details. The
caller_identity field (set automatically from authentication) serves
the same purpose.
Context: source was a user-declared label (“rpms-ci”,
“dependency-updater”) while caller_identity is the authenticated
identity. Having both was redundant.
Alternatives: Keep both for cases where a shared SA submits on behalf of different logical sources.
Rationale: Removed until needed. caller_identity captures who
submitted. If per-source filtering is needed later, it can be
re-introduced as a field on MRWorkItemCreate.
DDR-14: attempts counts consecutive failures, not total actions
Decision: attempts increments only on executor failure and resets
to 0 on success.
Context: The initial implementation incremented attempts before
every action execution. A work item chaining 4 actions (create_mr →
merge → trigger_build → complete) would exhaust MAX_ATTEMPTS on the
third action.
Alternatives: (a) Separate total_actions counter. (b) Only count
failures.
Rationale: Option (b). attempts answers “how many consecutive
times has this action failed?” — the signal for escalation. Total
actions executed is an audit concern, not a processing concern.
DDR-15: Executor applies state directly; decider only picks next action
Decision: Executors directly modify item.phase, item.mr_iid,
etc. The decider only returns the next pending_action.
Context: Initially the processing loop set the phase based on
ExecutorResult.success. This mixed two concerns.
Alternatives: (a) Processing loop sets phase. (b) Decider sets phase. (c) Executor sets phase.
Rationale: Option (c). The executor knows what happened (“I created an MR, so phase stays active”). Events that update dimensions also directly set state. The pattern is the same: something happens → state is updated → decider evaluates for the next action. Symmetry with event-driven dimension updates.
DDR-16: Items stay active after MR creation
Decision: After the create_mr executor succeeds, the item remains
in active phase with pending_action = None.
Context: After MR creation, the decider returns None (no action).
The item sits in active indefinitely.
Alternatives: (a) Set phase to completed after MR creation.
(b) Keep active for future event-driven lifecycle tracking.
Rationale: Option (b). The active phase covers the entire lifecycle
— from submission through CI, approval, merge, and post-merge tracking.
The decider returns no actions until event-driven dimension updates
(future SQS consumer) trigger the next step.
DDR-17: Audit log to S3, not database
Decision: Service-level audit (state transitions, decider decisions, actions taken) goes to S3 as immutable objects.
Context: Raw webhook events are already archived by
sns-s3-archiver. The service needs its own audit trail for state
transitions and decider evaluations.
Alternatives: (a) Database table (work_item_events). (b) S3
immutable objects. (c) Both.
Rationale: Option (b). S3 is immutable and cheap. The operational
DB stays lean. Raw events are already in S3 via sns-s3-archiver;
service-level audit captures the service’s reaction to those events.
DDR-18: Agent direction — dumber, not smarter
Decision: The hummingbird-agent should become a pure compute engine. The work queue service is the MR lifecycle orchestrator. The agent never calls the work queue service; the work queue service calls the agent.
Context: HUM-857 proposed adding create_merge_request as a new
action in the agent’s actions.py. This was the wrong direction.
Alternatives: (a) Agent gains more actions (push, merge, MR management). (b) Agent becomes stateless compute; work queue service orchestrates.
Rationale: Option (b). The agent keeps LLM loop, tool registry, and sandbox — nothing else. Event routing, rate limiting, placeholder notes, result posting, session management, and all GitLab writes move to the work queue service. HUM-857 closed as Obsolete.
DDR-19: Shared database
Decision: The work queue service uses the same events PostgreSQL
database as hummingbird-status and hummingbird-dashboard.
Context: Cross-service joins (e.g. work_items JOIN
gitlab_merge_requests JOIN pipelineruns) are useful for the dashboard.
Alternatives: Separate database per service.
Rationale: Shared DB with service-specific Alembic migration chains
(alembic_version_mr_service). Each service manages its own tables
independently. The migration chain is isolated so one service can evolve
its schema without affecting others.
4. Future Work
Event-driven dimension updates
Subscribe to the existing SNS topic (same pattern as
hummingbird-agent’s SQS subscription). CI pipeline events update the
ci dimension, MR approval events update approval, Konflux
PipelineRun events update konflux_build/konflux_release. Each event
upserts one (dimension, variant) row and triggers the decider.
CVE work item type
Add cve/ subpackage with CveWorkItem, CVE-specific decider
(analyze → label → create advisory MR → check VEX → close), and
CVE-specific executors. No changes to the generic layer.
Authorization
Add authorization layer: OpenShift group check for humans (reusing dashboard pattern), application-level SA identity allowlist for in-cluster services. Read endpoints open to all authenticated callers, write endpoints gated per type.
Content validation and size limits
Add content-level inspection to the pre-commit checks: maximum file size, binary detection, and content pattern matching. Currently only path blocklists and action validation are enforced.
Reconciliation sweep
Periodic job queries GitLab API for items in active phase, compares
actual state with dimension rows, corrects drift from missed events.
Service rename
The service is currently named hummingbird-mr-service from its
origins as an MR-only service. A rename to hummingbird-work-queue or
similar is deferred until a second work item type is implemented.