15 KiB
Redmine Event Outbox Spec
Purpose
Add a low-risk event boundary around the legacy Redmine install so external tools can react to Redmine changes without polling heavily and without making Redmine dependent on a message bus, search service, or experimental automation code.
The first version should capture issue and journal activity into a local database outbox table. A separate worker can later publish those rows to Redis, RabbitMQ, a webhook, or a search/indexing service.
Goals
- Record issue creation and update events.
- Record issue journal/comment events.
- Record contact creation and update events from
redmine_contacts. - Keep Redmine issue saves working even if external messaging/search systems are down.
- Make event processing replayable from a durable local table.
- Keep the plugin small and compatible with Redmine 3.4.4-era plugin patterns.
- Support future indexing for queries like "recent events for customer A".
Non-Goals For V1
- No direct Redis/RabbitMQ publish inside Redmine request callbacks.
- No semantic/vector search inside Redmine.
- No replacement of Redmine email handling in the first version.
- No guarantee that external consumers receive each event exactly once.
- No broad UI in Redmine beyond optional admin/status views later.
- No binary image indexing inside Redmine.
Safety Rule
Redmine must not fail an issue create/update because a broker, worker, search index, DNS lookup, network call, or external service failed.
V1 should therefore only write to Redmine's own database from inside Redmine. All network publishing happens outside Redmine in a worker.
There is one important tradeoff:
- If outbox insert failures are rescued, ticket saves are maximally protected but rare event loss is possible.
- If outbox rows are written transactionally and failures are not rescued, event durability is stronger but ticket saves can fail because the outbox write failed.
For this project, prefer protecting ticket saves. The outbox insert should be simple and local, and failures should be logged with enough detail to diagnose.
Proposed Plugin
Plugin name:
redmine_event_outbox
Directory:
redmine-copy/plugins/redmine_event_outbox/
Initial structure:
init.rb
db/migrate/001_create_event_outbox_events.rb
app/models/event_outbox_event.rb
lib/redmine_event_outbox.rb
lib/redmine_event_outbox/hooks/issues_hook.rb
lib/redmine_event_outbox/patches/contact_patch.rb
lib/redmine_event_outbox/patches/journal_patch.rb
lib/tasks/redmine_event_outbox.rake
Event Table
Table name:
event_outbox_events
Columns:
id integer primary key
event_type string, required
source_type string, required
source_id integer, required
project_id integer, nullable
issue_id integer, nullable
journal_id integer, nullable
user_id integer, nullable
occurred_at datetime, required
payload text/json, required
processed_at datetime, nullable
attempts integer, default 0
last_error text, nullable
locked_at datetime, nullable
locked_by string, nullable
created_at datetime
updated_at datetime
Production Redmine currently uses MySQL. Store payload as text for Redmine
3.4.4 compatibility and serialize JSON in application code. Do not rely on
native MySQL JSON behavior in V1.
Useful indexes:
index_event_outbox_events_on_processed_at_id
index_event_outbox_events_on_event_type
index_event_outbox_events_on_issue_id
index_event_outbox_events_on_project_id
index_event_outbox_events_on_occurred_at
V1 Event Types
issue.created
Created after a new issue is saved.
Trigger candidate found in Redmine 3.4.4:
controller_issues_new_after_save
Payload:
{
"event_type": "issue.created",
"issue_id": 123,
"project_id": 4,
"tracker_id": 1,
"status_id": 1,
"priority_id": 2,
"author_id": 5,
"author_name": "Jane User",
"assigned_to_id": null,
"assigned_to_name": null,
"subject": "Example",
"created_on": "2026-04-21T12:00:00Z",
"updated_on": "2026-04-21T12:00:00Z"
}
issue.updated
Created after an existing issue update succeeds.
Trigger candidate found in Redmine 3.4.4:
controller_issues_edit_after_save
Payload should include issue identity plus the current journal id when present:
{
"event_type": "issue.updated",
"issue_id": 123,
"journal_id": 456,
"project_id": 4,
"status_id": 2,
"assigned_to_id": 7,
"assigned_to_name": "Support User",
"actor_id": 5,
"actor_name": "Jane User",
"subject": "Example",
"updated_on": "2026-04-21T12:15:00Z"
}
journal.created
Created when a journal row for an issue is committed.
Trigger candidate found in Redmine 3.4.4:
Journal.after_commit :on => :create
The plugin can patch Journal with a separate after_commit callback.
Payload:
{
"event_type": "journal.created",
"journal_id": 456,
"issue_id": 123,
"project_id": 4,
"user_id": 5,
"user_name": "Jane User",
"subject": "Example",
"private_notes": false,
"has_notes": true,
"changed_fields": ["status_id", "assigned_to_id"],
"created_on": "2026-04-21T12:15:00Z"
}
V1 should not put full private note text into the payload. The indexing worker can fetch detail with appropriate credentials if needed.
contact.created
Created after a contact is committed.
Trigger candidate:
Contact.after_commit :on => :create
The plugin can patch Contact from redmine_contacts when that plugin is
installed.
Payload:
{
"event_type": "contact.created",
"contact_id": 321,
"project_ids": [4],
"is_company": false,
"name": "Customer Name",
"company": "Customer Company",
"author_id": 5,
"author_name": "Jane User",
"assigned_to_id": 7,
"assigned_to_name": "Support User",
"created_on": "2026-04-21T12:20:00Z",
"updated_on": "2026-04-21T12:20:00Z"
}
contact.updated
Created after a contact update is committed.
Trigger candidate:
Contact.after_commit :on => :update
Payload:
{
"event_type": "contact.updated",
"contact_id": 321,
"project_ids": [4],
"is_company": false,
"name": "Customer Name",
"company": "Customer Company",
"actor_id": 5,
"actor_name": "Jane User",
"assigned_to_id": 7,
"assigned_to_name": "Support User",
"updated_on": "2026-04-21T12:25:00Z"
}
Contact payloads should include enough human context for downstream consumers to decide whether to fetch the full contact. Avoid embedding all phone/email/address data in V1 payloads unless a consumer proves it needs those fields inline.
helpdesk_ticket.created
Created after a HelpdeskTicket row is committed when
redmine_contacts_helpdesk is installed.
Payload:
{
"event_type": "helpdesk_ticket.created",
"helpdesk_ticket_id": 10,
"issue_id": 123,
"project_id": 4,
"contact_id": 321,
"message_id": "<message@example>",
"is_incoming": true,
"source": 0,
"from_address": "customer@example.com",
"to_address": "support@example.com",
"cc_address": null,
"subject": "Example",
"ticket_date": "2026-04-21T12:00:00Z"
}
helpdesk_ticket.updated
Created after an existing HelpdeskTicket row is updated. This is useful when
the contact, source, or message metadata is corrected after ticket creation.
journal_message.created
Created after a JournalMessage row is committed. This is the authoritative
per-email metadata layer for helpdesk conversation search.
Payload:
{
"event_type": "journal_message.created",
"journal_message_id": 20,
"journal_id": 456,
"issue_id": 123,
"project_id": 4,
"contact_id": 321,
"message_id": "<reply@example>",
"is_incoming": false,
"source": 0,
"from_address": "support@example.com",
"to_address": "customer@example.com",
"cc_address": null,
"has_bcc_address": false,
"private_notes": false,
"has_notes": true,
"message_date": "2026-04-21T12:15:00Z"
}
The event records whether BCC metadata exists but does not store the BCC address itself.
journal_message.updated
Created after an existing JournalMessage row is updated. This lets downstream
indexes repair message-level documents when message metadata is corrected.
Payload Context Policy
Payloads should be lightweight but useful. Include:
- ids needed for follow-up fetches
- event type and timestamp
- issue/contact subject or display name
- user id and user name when known
- project id(s)
- changed field names when available
Avoid:
- full private notes
- large descriptions/background fields
- attachments or binary content
- full email bodies
- BCC addresses
- large custom field dumps
This gives consumers enough information to decide whether they care about an event while keeping the outbox table compact and lower-risk.
Delivery Semantics
V1 should provide at-least-once processing from the outbox to downstream systems.
Consumers must be idempotent. Use the outbox row id as the event id:
{
"event_id": 98765,
"event_type": "issue.updated"
}
Duplicates are acceptable. Silent message loss is not acceptable once an outbox row exists.
Worker/Rake Task
Current implemented Redmine-side worker-facing command:
bundle exec rake redmine_event_outbox:dump RAILS_ENV=production
This prints pending rows as JSON and does not mark them processed.
Historical planned Redmine-side publish command:
bundle exec rake redmine_event_outbox:publish RAILS_ENV=production
V1 modes:
# Print pending rows as JSON without marking processed.
bundle exec rake redmine_event_outbox:dump
# Process pending rows and mark success.
bundle exec rake redmine_event_outbox:publish
# Retry failed/unprocessed rows.
bundle exec rake redmine_event_outbox:publish RETRY=1
The current external publisher is redmine_outbox_worker.py. Its initial
publisher target is JSONL at /tmp/redmine-outbox/derived_documents.jsonl,
which lets us test the Redmine side before choosing Redis Streams or RabbitMQ.
The worker stays small and conservative:
- select pending rows in id order
- lock or claim a bounded batch
- publish each row
- mark
processed_atonly after publish succeeds - increment
attemptsand writelast_erroron failure - leave failed rows available for retry
- make duplicate delivery acceptable to consumers
Current external worker commands:
./redmine_outbox_worker.py --status
./redmine_outbox_worker.py --dry-run --batch-size 10
./redmine_outbox_worker.py --batch-size 20
./redmine_outbox_worker.py --purge-processed-days 30
./redmine_outbox_worker.py --purge-processed-days 30 --apply-purge
Processed rows are retained by default. Purge is explicit, age-gated, and intended for test-instance cleanup, not normal delivery.
Later publisher targets:
- Redis Streams
- RabbitMQ
- webhook HTTP POST
- local search/indexing service
Search/Indexing Direction
The semantic/fuzzy search system should be external to Redmine.
The first external search index should have strong vector-search support because future work will use embeddings heavily, including image embeddings. Redmine should only emit events and identifiers; the external indexer should fetch, transform, chunk, embed, and store searchable material.
Likely derived entities:
- contacts
- issues
- journals
- helpdesk email messages
- status/assignee changes
- timestamps and project links
- future image/document embeddings
Likely query examples:
recent events for customer A
open issues involving customer A
recent emails from customer A
status changes for customer A this week
The outbox does not need to contain all searchable text. It only needs enough identity and timestamps for a worker to fetch and update the external index.
Candidate vector-capable search/index options to evaluate later:
- PostgreSQL with
pgvector - Qdrant
- Weaviate
- OpenSearch/Elasticsearch with vector fields
- LanceDB
Since production Redmine uses MySQL, the search/index database should be treated as a separate derived system rather than as a feature of the Redmine database.
Email Handling Direction
Email handling should be handled after the basic issue/journal outbox is stable.
Observed areas to inspect further:
- Redmine core
MailHandler redmine_contactsrake tasks underlib/tasks/contacts_email.rakeredmine_contacts_helpdeskhelpdesk_mailerendpoint and related mail handling code
Future event types may include:
email.received
email.ignored
email.created_issue
email.updated_issue
helpdesk_ticket.created
helpdesk_ticket.updated
Custom mail routing should have a safe fallback to current behavior if external decision code fails.
Implementation Phases
Phase 1: Outbox Skeleton
- Status: implemented and tested on the LAN Redmine copy.
- Created plugin.
- Added migration and model.
- Added helper method for safe event creation.
- Added issue create/update hooks.
- Added basic rake task to dump pending rows.
- Verified issue create/update writes outbox rows in the
Meetingsproject.
Phase 2: Journal Events
- Status: implemented and tested on the LAN Redmine copy.
- Patched
Journalwithafter_commit. - Records
journal.created. - Includes changed field names from journal details.
- Avoids full private note content in payload.
Phase 3: Contact Events
- Status: implemented and tested on the LAN Redmine copy.
- Patched
Contactfromredmine_contactsif available. - Records
contact.created. - Records
contact.updated. - Includes contact display name, company, project ids, and relevant user context.
- Keeps payloads small; full contact detail can be fetched by external workers.
Phase 4: Worker
- Add locking fields.
- Process pending rows in batches.
- Mark
processed_atonly after successful publish. - Track attempts and last error.
- Support dry-run/dump mode.
Phase 5: External Index Prototype
- Build a small external worker outside Redmine.
- Read outbox rows.
- Fetch affected issue/contact/journal details through API or DB.
- Store in a vector-capable search database.
- Add a CLI for recent customer timelines.
Phase 6: Message Bus
- Choose Redis Streams or RabbitMQ based on actual consumer needs.
- Keep Redmine unchanged; only the worker publisher changes.
Open Questions
- Do production processed rows need a formal retention window, or should they be retained until external index/message-bus replay policy is settled?
- Do we need a Redmine admin page to inspect outbox health, or is a rake task/log enough?
- What is the current production mail ingestion path?
- Which vector-capable external search index should be used first?
- Should contact payloads include normalized primary email/phone, or is that too much inline data?
Current Checkpoint
Known-good archive:
dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz
Manifest:
dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.MANIFEST.md
Tested on LAN Redmine copy:
- Migrated successfully.
- Passenger restart via
touch tmp/restart.txt. - Verified
issue.created. - Verified
issue.updated. - Verified
journal.created. - Verified
contact.created. - Verified
contact.updated. - Verified
redmine_event_outbox:dump.
Test artifacts left on LAN copy for traceability:
- Issue
#39858in projectMeetings. - Contact
#4337in projectEmail Test. - Six outbox rows in
event_outbox_events.