Scale and Reliability is about making large work feel ordinary. You can run bulk mutations, process automations, preserve ticket governance, and keep boards personalized while Pomavo moves side effects through durable background infrastructure.
Kafka becomes the job backbone
Pomavo moved job delivery to Kafka so background work is fed by a durable, partitioned log. A user action still records the core database change first, then the side effects move through the Job Pipeline: indexing, history, inbox notifications, attachment tracking, automations, and scheduler resumes.
Kafka gives Worker replicas a shared consumer group. Work is balanced across partitions, offsets are committed after successful processing, and partitions preserve order where the partition key requires it. This keeps related work predictable while allowing unrelated jobs to run in parallel.
| Pipeline stage | What happens |
|---|---|
| API event | The Web API persists the user action and emits a typed event |
| Job record | Pomavo creates a durable job record you can inspect |
| Kafka publish | The job event is published to the job topic |
| Worker feeder | A Kafka feeder consumes events in the worker group |
| Partition lane | Each partition is processed sequentially with its own channel |
| Handler execution | The orchestrator resolves the correct handler and records a run |
| Sub-job fan-out | Handlers create independently tracked child jobs |
The result is a pipeline that can absorb more work without turning every API request into a synchronous chain of side effects.
MongoDB stores jobs and run logs
Jobs and run logs moved to MongoDB because their shape is flexible. A job needs a payload, status, parent and child relationships, per-run state, result data, and many log lines that differ by handler type.
This storage model pairs well with the Admin Job Explorer. You can open a job, see each run, inspect status transitions, read the full log, and follow child jobs created by a parent operation.
| Job data | Why document storage fits |
|---|---|
| Payload | Different job types carry different JSON shapes |
| Runs | A job can be retried, requeued, succeeded, errored, or terminated |
| Log lines | Handlers record detailed progress and diagnostics |
| Child jobs | Fan-out creates a tree of related work |
| Results | Each handler returns type-specific summary data |
The job record is the durable unit of work. The run log is the evidence trail you use when you need to know exactly what happened.
Worker resilience and startup readiness
The Kafka feeder now has resilience built into the worker startup path. It waits behind a startup readiness gate, connects to Kafka, marks the Kafka consumer prerequisite ready, and retries connection failures with a bounded backoff before marking the prerequisite failed.
Once running, the feeder creates a processing lane per assigned partition. If a lane buffer fills, the feeder pauses that partition while continuing to service others. When the buffer drains, the partition resumes. This gives you backpressure without stopping the whole worker.
| Resilience behavior | Reliability effect |
|---|---|
| Startup gate | The worker does not report ready before Kafka consumption is established |
| Manual offsets | Offsets are committed after each message is processed |
| Partition pause and resume | Hot partitions stop flooding full internal queues |
| Dead letter topic | Unexpected processing failures can be routed away from the main stream |
| Health logging | Long-running jobs and queue depth are visible in logs |
| Bounded reconnect loop | Transient Kafka failures retry before the worker gives up |
Distributed DSL mutations
Bulk DSL mutations are now asynchronous and distributed. When you run a mutation from the Query Language, Pomavo creates a coordinator job. The coordinator parses the query, validates that mutation clauses are present, collects matching tickets, and splits the work into batch sub-jobs.
template = "Bug" and priority = "Critical" and status != "Done"
set status = "In Progress", assignee = @meA standalone create mutation runs inline in the coordinator because there is no source-ticket set to fan out. Mutations that operate on existing tickets use batch sub-jobs so Worker replicas can process slices in parallel.
Parse and validate
The coordinator tokenizes and parses the DSL, then rejects non-mutation queries or invalid mutation filters.
Collect matches
It queries the search index for every matching source ticket, respecting an explicit limit when present.
Create batches
It chunks the matches into batches of 100 tickets.
Dispatch sub-jobs
Each batch gets a partition key based on the parent job and batch index so work spreads across stream partitions.
Aggregate results
Each batch logs update, create, link, unlink, comment, success, failure, throughput, and error counts.
Search_after pagination for large result sets
The coordinator uses search_after pagination instead of offset pagination when collecting tickets for a mutation. That matters for large result sets because Elasticsearch can page forward using a cursor rather than repeatedly skipping from the beginning.
The implementation fetches pages of 1000 results, stores the returned cursor, and continues until it reaches the query limit, an empty page, or a missing cursor. The log records page count, total tickets found, and collection time so you can see whether time was spent searching or executing.
| Collection detail | Value |
|---|---|
| Page mechanism | search_after cursor |
| Fetch page size | 1000 tickets |
| Batch chunk size | 100 tickets per mutation sub-job |
| Limit handling | Stops at the query limit when provided |
| Logging | Page count, total tickets, and elapsed time |
This is the kind of plumbing you rarely see in the product, but you feel it when a large mutation completes as background work instead of freezing the browser.
Caller impersonation in async mutations
Async work still runs as you. The mutation coordinator and each batch sub-job set the organization context and user context before calling domain services. That caller impersonation keeps permission checks, audit behavior, tenant scope, and business rules aligned with the original request.
This mirrors the security approach in the Security Model: the UI, API, query mutations, and automations all go through the same permission-gated services. Moving work to Kafka does not create a privileged shortcut.
| Context | Preserved for |
|---|---|
| Organization id | Tenant scoping across search, repositories, and services |
| User id | Permission checks and user-attributed changes |
| Query text | Reparse and execute the exact mutation in every batch |
| Source ticket snapshot | Apply clauses to the intended set of tickets |
| Parent job id | Link sub-jobs back to the originating mutation |
Bulk mutations still affect every matching ticket. Run the DSL as a plain search first, then add the mutation clause when you are confident in the result set.
Field locks protect work in flight
Ticket Locks now give you hard governance controls for specific parts of a ticket. A lock can prevent field edits, link changes, attachment changes, status transitions, or new comments until an unlock path runs.
| Lock type | Prevents |
|---|---|
| Field | Editing a specific field value |
| Link | Adding or removing ticket links |
| Attachment | Adding or removing attachments |
| Status Transition | Moving the ticket to another workflow state |
| Comment Add | Posting new comments |
Locks are enforced whether a change comes from a person in the UI or from automation. That makes them useful for release freeze rules, approval gates, controlled transitions, and audit-sensitive records.
Multi-user fields and avatar stacks
Multi-user fields now render as overlapping avatar stacks. You can scan contributors, reviewers, watchers, or shared owners quickly without turning every card into a long list of names.
The visual pattern is small, but it affects boards and ticket previews every day. Dense team fields remain readable, while hover and detail views can still provide the full user context when you need it.
| UI pattern | Why it helps |
|---|---|
| Overlapping avatars | Shows several users in compact space |
| Consistent ordering | Keeps the stack stable as cards refresh |
| Overflow handling | Avoids stretching cards for large groups |
| Shared field rendering | The same multi-user field can appear across templates |
DSL functions and aggregations graduate into dedicated projects
The DSL implementation was extracted into dedicated Pomavo.Dsl and Pomavo.Dsl.Elasticsearch projects. That separation makes the language easier to test, reuse, and evolve without entangling parser logic with API controller code.
The user-facing language still supports filters, sorting, pagination, references, date literals, bulk mutations, and aggregation. What changed is maintainability: functions and aggregations can now be developed as a focused language layer.
group by assignee
return assignee, count() as open_tickets, sum(quot;Story Points") as points
order by points desc| DSL capability | Examples |
|---|---|
| Date functions | now(), today(), startOfDay(date), startOfWeek(date) |
| Value functions | length(value) |
| Aggregations | count(), sum(expr), avg(expr), min(expr), max(expr) |
| References | @me, @username, $field, quot;Field Name" |
| Mutations | set, create, link, unlink, comment |
Persisted board preferences
Project boards now remember per-user view preferences. You can return to the board with your chosen presentation intact rather than rebuilding the same working view every session.
This is deliberately user-scoped. Your board density, grouping, filtering, or display choices do not force the same view onto teammates. Each person gets a board that stays close to how they work.
| Preference behavior | Product effect |
|---|---|
| Saved per user | Your board setup follows your account |
| Scoped to board context | Preferences do not leak across unrelated places |
| Restored on return | You spend less time resetting the UI |
| Compatible with saved queries | Team-level saved searches and personal view state can coexist |
Load tests and operational confidence
The k6 load-test suite gives you a repeatable way to exercise the scale work. It is especially valuable for paths where user actions trigger background jobs, search queries, and worker fan-out.
The goal is confidence under realistic pressure: the API should stay responsive, the worker should keep draining Kafka partitions, MongoDB logs should remain inspectable, and admin views should show the run tree when something slows down.
Durable delivery
Kafka carries jobs through a partitioned log with worker consumer groups.
Inspectable storage
MongoDB stores flexible job payloads, runs, results, and logs.
Distributed mutations
Large DSL mutations split into 100-ticket batch sub-jobs.
Governed tickets
Locks protect fields, links, attachments, transitions, and comments.
Next, this reliability foundation gives you room for larger automations, richer reports, and more integration work while preserving the same tenant and permission boundaries.