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 the merge works

The ListTickets handler in service_desk_handler.go calls mergePendingWrites():

  1. Query ClickHouse for tickets (normal flow)
  2. Query PostgreSQL for non-expired pending writes
  3. For each create_ticket pending write:
    • Extract fields from payload: summary, priority, issue_type
    • Extract fields from jira_response: SUP key, reporter name/email, status, created date
    • Create a synthetic ticketRow with these fields
    • Prepend to the ClickHouse results
  4. For transition pending writes, update the status on the matching ClickHouse row
  5. Return merged results

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_servicedesk_pending_writes gauge; proxima_servicedesk_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)🔄 In progress
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