604 lines
15 KiB
Markdown
604 lines
15 KiB
Markdown
# 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:
|
|
|
|
```text
|
|
redmine_event_outbox
|
|
```
|
|
|
|
Directory:
|
|
|
|
```text
|
|
redmine-copy/plugins/redmine_event_outbox/
|
|
```
|
|
|
|
Initial structure:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
event_outbox_events
|
|
```
|
|
|
|
Columns:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```ruby
|
|
controller_issues_new_after_save
|
|
```
|
|
|
|
Payload:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```ruby
|
|
controller_issues_edit_after_save
|
|
```
|
|
|
|
Payload should include issue identity plus the current journal id when present:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```ruby
|
|
Journal.after_commit :on => :create
|
|
```
|
|
|
|
The plugin can patch `Journal` with a separate `after_commit` callback.
|
|
|
|
Payload:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```ruby
|
|
Contact.after_commit :on => :create
|
|
```
|
|
|
|
The plugin can patch `Contact` from `redmine_contacts` when that plugin is
|
|
installed.
|
|
|
|
Payload:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```ruby
|
|
Contact.after_commit :on => :update
|
|
```
|
|
|
|
Payload:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
bundle exec rake redmine_event_outbox:publish RAILS_ENV=production
|
|
```
|
|
|
|
V1 modes:
|
|
|
|
```sh
|
|
# 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_at` only after publish succeeds
|
|
- increment `attempts` and write `last_error` on failure
|
|
- leave failed rows available for retry
|
|
- make duplicate delivery acceptable to consumers
|
|
|
|
Current external worker commands:
|
|
|
|
```sh
|
|
./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:
|
|
|
|
```text
|
|
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_contacts` rake tasks under `lib/tasks/contacts_email.rake`
|
|
- `redmine_contacts_helpdesk`
|
|
- `helpdesk_mailer` endpoint and related mail handling code
|
|
|
|
Future event types may include:
|
|
|
|
```text
|
|
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 `Meetings` project.
|
|
|
|
### Phase 2: Journal Events
|
|
|
|
- Status: implemented and tested on the LAN Redmine copy.
|
|
- Patched `Journal` with `after_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 `Contact` from `redmine_contacts` if 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_at` only 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:
|
|
|
|
```text
|
|
dist/redmine_event_outbox-0.0.1-known-good-20260421T143957Z.tar.gz
|
|
```
|
|
|
|
Manifest:
|
|
|
|
```text
|
|
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 `#39858` in project `Meetings`.
|
|
- Contact `#4337` in project `Email Test`.
|
|
- Six outbox rows in `event_outbox_events`.
|