Skip to main content

Data Sync Architecture

The service desk relies on a multi-system data pipeline to keep Console, Jira, and ClickHouse in sync. This page documents the complete flow for ticket creation, updates, and the ETL process.

System Overview​

Design: Option D — Console-Side Merge​

The system uses a single ClickHouse writer (Dagster) combined with PostgreSQL pending writes for instant visibility. This avoids schema duplication across systems while keeping the user experience instant.

Why this design?​

ConcernHow it's handled
Instant visibility after creationPending write in PostgreSQL, merged into list results
Single ClickHouse writerDagster only — no schema duplication
Reliable SUP→PXD creationTemporal with 3-layer idempotency
Full ticket dataDagster syncs all 23 columns within ~1 min
Jira webhook delays (1-40 min)Console calls Temporal gateway directly — no dependency on Jira webhooks

What the user sees​

TimeSourceWhat's displayed
0sPending write (PostgreSQL)SUP key, summary, priority, reporter, status "New"
~5sTemporal creates PXD(invisible — happens in background)
~1 minDagster syncs to ClickHouseFull data: PXD key, assignee, team, all 23 columns
30 minPending write expiresClickHouse data takes over (already there from ~1 min)

Ticket Creation Flow​

Pending Writes (Option D Detail)​

The service_desk_pending_writes table in PostgreSQL bridges the gap between ticket creation and Dagster sync:

Schema​

ColumnTypeDescription
idUUIDPrimary key
client_idUUIDFK to clients — for access scoping
issue_keyVARCHAR(50)SUP-xxxx from JSM response
actionVARCHAR(20)create_ticket or transition
payloadJSONBOriginal create request (summary, priority, type)
jira_responseJSONBFull JSM API response (reporter, status, dates)
created_atTIMESTAMPTZWhen the write was recorded
expires_atTIMESTAMPTZnow() + 30 minutes — auto-cleanup

How pending writes are read​

The ticket list no longer merges pending writes

GET /api/v1/service-desk/tickets is served from the Postgres read model (service_desk_tickets), which is event-fed rather than an hourly mart, so there is nothing for the list to compensate for. The mergePendingWrites() function this section used to describe has been deleted. A ticket created through Console appears in the list once the projector has seen it.

A transition pending write is therefore no longer read by anything; it is still recorded, and retires with the table.

One reader remains: the ticket detail endpoint, GET /api/v1/service-desk/tickets/{issueKey}. When the key is not in the read model yet, GetTicket looks up a pending write for it and builds an optimistic row via pendingWriteToTicketRow(), so a deep link to a just-created ticket resolves instead of 404-ing:

  1. Query the Postgres read model for the ticket (normal flow)
  2. On a miss, query PostgreSQL for a non-expired create_ticket pending write for that key
  3. Extract the summary from payload.request_field_values
  4. Extract SUP key, reporter name/email, status and created date from jira_response
  5. Return the synthetic ticketRow with optimistic: true

Any non-create_ticket pending write is ignored (the helper returns false).

Fields available from pending write​

FieldSourceAvailable?
SUP keyjira_response.issueKey✅
Summarypayload.summary or jira_response.summary✅
Prioritypayload.priority✅
Issue typepayload.issue_type✅
Statusjira_response.currentStatus.status✅
Status categoryjira_response.currentStatus.statusCategory✅
Reporter namejira_response.reporter.displayName✅
Reporter emailjira_response.reporter.emailAddress✅
Created datejira_response.createdDate.iso8601✅
PXD key—❌ (not created yet)
Assignee—❌
Team—❌
Project label—❌
Due date—❌
Resolved—❌
SLA data—❌

After Dagster syncs (~1 min), all 23 columns become available from ClickHouse.

Temporal: SUP → PXD Workflow​

Architecture​

  • Gateway: Stateless Go HTTP service. Receives webhook from Console backend (and Jira as backup). Calls start_workflow with id = sup-to-pxd-{SUP-key}.
  • Worker: Go binary polling task queue jira-sup-pxd. Runs the SupToPxdWorkflow.
  • Config DB: PostgreSQL tables for team→component mappings, idempotency keys, feature flags.

Workflow steps​

  1. Load config from Postgres (mode, mappings, excluded actors)
  2. Feature flag check: disabled / shadow / label_opt_in / full
  3. Load SUP issue from Jira (fresh GET, don't trust webhook payload)
  4. Idempotency check (3 layers):
    • Workflow ID collision (Temporal built-in)
    • SUP customfield_10150 already set (Jira-side)
    • Postgres jira_idempotency_keys table (in_flight → committed)
  5. Fetch org details from Atlassian CSM API
  6. Derive PXD payload (issue type routing, team→component mapping)
  7. Reserve idempotency key (Postgres INSERT, state=in_flight)
  8. Create PXD issue in Jira (POST /rest/api/3/issue)
  9. Commit idempotency key (UPDATE state=committed)
  10. Update SUP syncer field (customfield_10150 = PXD-xxxx)

Retry policies​

Activity typeMax attemptsBackoffNon-retryable
Read (Jira/Postgres GET)102× from 1s404, 401
Postgres write82× from 1sunique_violation (handled)
Jira POST/PUT52× from 2s4xx except 429

Cutover phases​

PhaseModeBehavior
0disabledWorkflow exits immediately, no side effects
1shadowFetches SUP, derives payload, logs — no PXD created
2label_opt_inOnly creates PXD for SUP issues with opt-in label
3fullCreates PXD for all SUP issues

Dagster: Jira Mirror ETL​

Dagster is the sole writer to ClickHouse jira.jira_issues. Runs as a cron every 1 minute:

Schedule (every 1 min)
→ JQL: project=PXD AND updated >= 15 minutes ago
→ Transform to 23-column ClickHouse schema
→ INSERT INTO jira.jira_issues (batch, JSONEachRow)
→ OPTIMIZE TABLE jira.jira_issues FINAL
→ Audit log to jira.jira_sync_audit
→ Stale check → Telegram alert if no sync in 15+ min

ClickHouse schema (23 columns)​

ColumnJira Source
issue_keyissue.key (PXD-xxxx)
linked_issue_keycustomfield_10150 (SUP-xxxx)
summaryfields.summary
statusfields.status.name
status_categoryfields.status.statusCategory.name
createdfields.created
updatedfields.updated
resolvedfields.resolutiondate
reporter_namecustomfield_10183
reporter_emailcustomfield_10878
assigneefields.assignee.displayName
project_keyfields.project.key
project_namefields.project.name
issue_typefields.issuetype.name
priorityfields.priority.name
severitycustomfield_10049.value
project_labelfields.labels[0]
team_nameComponent name
linked_issue_keycustomfield_10150
duedatefields.duedate
flaggedcustomfield_10021 contains "Impediment" → 1
startedcustomfield_10015
timeoriginalestimatecustomfield_10645

What Dagster catches​

  • Updates to existing tickets (status changes, field edits)
  • Tickets created outside Console (Jira portal, email, API)
  • Any tickets missed by Temporal (shouldn't happen, but safety net)
  • Comment sync, attachment sync metadata

Console → Temporal Gateway Call​

After CreateTicket succeeds in the Console backend:

// Fire-and-forget POST to Temporal gateway
go func() {
body, _ := json.Marshal(map[string]string{"sup_issue_key": result.IssueKey})
req, _ := http.NewRequest("POST", cfg.TemporalGatewayURL+"/jira/sup-issue-created", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-Secret", cfg.TemporalWebhookSecret)
resp, err := http.DefaultClient.Do(req)
if err != nil || resp.StatusCode >= 300 {
slog.Warn("temporal gateway call failed", "error", err, "issue_key", result.IssueKey)
}
if resp != nil { resp.Body.Close() }
}()

This runs in a goroutine — doesn't block the API response. If it fails, the Jira webhook (backup) or manual retry handles it.

Failure Scenarios​

ScenarioImpactRecovery
Temporal gateway downPXD not created immediatelyJira webhook retries; pending write visible for 30 min
Temporal worker downWorkflow queued, executes when worker recoversTemporal guarantees execution
Dagster cron failsClickHouse staleTelegram alert; next cycle retries; pending write covers gap
Console backend downCan't create ticketUser sees error
ClickHouse downReads fail (503)Writes to JSM still work; pending writes visible
Jira downCan't create SUPUser sees error
Duplicate webhook (Console + Jira)Two start_workflow callsTemporal deduplicates by workflow ID

Monitoring​

SystemWhat to watch
Consoleproxima_service_desk_pending_writes gauge; proxima_service_desk_writes_total counter
TemporalWorkflow execution history in Temporal UI; workflow failure count
DagsterAsset materialization status; jira.jira_sync_audit table; stale data Telegram alerts
ClickHouseSELECT max(updated) FROM jira.jira_issues — should be within 2 min of now

Migration Status​

ComponentOld (n8n)NewStatus
SUP → PXD creationn8n sub-workflowTemporal workflow✅ Built, deploying
ClickHouse syncn8n ETL cron (1 min)Dagster mirror (1 min) + hourly marts✅ Live — Dagster is the sole writer
Instant visibilityn8n ClickHouse sub-workflowPending writes (Option D)✅ Live
PXD ↔ SUP syncn8n PXD↔SUP sync workflowTemporal (future)⏳ Planned
Console → gateway callN/ABackend HTTP POST⏳ Pending gateway URL