Skip to content

Make label_events writes idempotent on backfill (#25) - #26

Merged
anderdc merged 3 commits into
entrius:testfrom
hunnyboy1217:fix/25-idempotent-label-events
May 13, 2026
Merged

Make label_events writes idempotent on backfill (#25)#26
anderdc merged 3 commits into
entrius:testfrom
hunnyboy1217:fix/25-idempotent-label-events

Conversation

@hunnyboy1217

@hunnyboy1217 hunnyboy1217 commented May 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Make label_events writes idempotent so backfill re-runs (and BullMQ retries) no longer duplicate rows.

The webhook + backfill paths called labelEventRepo.save() without a natural-key conflict path. Because LabelEvent.id is @PrimaryGeneratedColumn() and label_events had no UNIQUE constraint, every backfill INSERTed a fresh row for every label event already in the table. The pr_labels_by_actor / issue_labels_by_actor views collapse duplicates via DISTINCT ON, so API output stayed correct while the table grew unbounded — eventually slowing the miners API as view scans degrade.

Changes:

  1. packages/db/07_label_events.sqlCREATE UNIQUE INDEX IF NOT EXISTS uq_label_events_natural_key on (repo_full_name, target_number, target_type, label_name, action, timestamp) NULLS NOT DISTINCT appended next to the existing index. NULLS NOT DISTINCT defends the rare case where target_number is NULL.
  2. packages/das/src/webhook/github-fetcher.service.tssaveLabelTimelineEvents: per-node repo.save() loop replaced with a single batched insert().values(rows).orIgnore().execute(). One round-trip per timeline instead of N, and ON CONFLICT DO NOTHING makes re-runs no-ops.
  3. packages/das/src/webhook/handlers/label.handler.ts — same save → insert().orIgnore() swap on the webhook write path. Defense-in-depth alongside the existing webhook_deliveries delivery-id dedup.

Deploy order for existing production databases:

  1. Deploy the code first (orIgnore() is a behavioral no-op until the constraint exists).
  2. Run the one-time dedupe DELETE to collapse existing duplicates (idempotent; no-op on a clean table):
-- Repeat until 0 rows affected for very large tables (batched form).
DELETE FROM label_events a
USING label_events b
WHERE a.id > b.id
  AND a.repo_full_name = b.repo_full_name
  AND a.target_number IS NOT DISTINCT FROM b.target_number
  AND a.target_type    = b.target_type
  AND a.label_name     = b.label_name
  AND a.action         = b.action
  AND a.timestamp      = b.timestamp;
  1. Add the unique index (use CONCURRENTLY on a running database to avoid locking the table):
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_label_events_natural_key
    ON label_events (repo_full_name, target_number, target_type,
                     label_name, action, timestamp)
    NULLS NOT DISTINCT;

Applying the index (step 3) before deduping (step 2) will fail with a unique-violation error if duplicate rows exist — that is the intended safety check. Fresh installs skip steps 2–3 entirely; the index is part of the initial schema in 07_label_events.sql.

Related Issues

Fixes #25

Type of Change

  • Bug fix
  • New feature
  • Refactor
  • Documentation
  • Other (describe below)

Testing

No test framework exists in this repo; verified manually.

Reproduction (matches issue #25 steps):

# 1. Register a repo with at least one labeled PR/issue
curl -X POST .../api/v1/admin/repos/register \
     -H "x-api-key: ..." \
     -d '{"repoFullName":"owner/repo"}'

# 2. Wait for backfill, then snapshot
docker exec das-postgres psql -U das -c \
  "SELECT COUNT(*) FROM label_events;"

# 3. Trigger a second backfill
curl -X POST .../api/v1/admin/backfill \
     -H "x-api-key: ..." \
     -d '{"repoFullName":"owner/repo"}'

# 4. Recount
docker exec das-postgres psql -U das -c \
  "SELECT COUNT(*) FROM label_events;"
  • Before fix: count doubles on step 4.
  • After fix: count is unchanged on step 4.

Build / lint:

  • npm run build — passes (NestJS compiler, full TS typecheck)
  • npm run lint — clean
  • npm run format:check — clean

Out of scope (separate follow-ups):

  • Webhook handler uses new Date().toISOString() while backfill uses GraphQL LabeledEvent.createdAt. The same logical labeling action can be represented by two rows with slightly different timestamps, which won't collide on the new UNIQUE. Bounded leak (≈1 extra row per labeling action that occurs while the app is running, per backfill that follows). Proper fix is to drop the webhook's label_events write entirely and enqueue a refresh job — architectural change deserving its own PR.
  • DISTINCT ON (repo, target, label_name) ORDER BY timestamp DESC in the labels views still seq-scans after dedupe. A purpose-built index would help, but is a separate perf PR.

Checklist

  • I have read the Contributing Guide
  • Code builds without errors
  • New and existing tests pass (if applicable) — n/a, no test suite in the repo
  • Documentation updated (if applicable) — deploy instructions and one-time migration SQL documented in PR description
  • No unnecessary dependencies added

@xiao-xiao-mao xiao-xiao-mao Bot added the bug Something isn't working label May 10, 2026

@anderdc anderdc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove packages/db/11_label_events_dedup.sql and packages/db/12_label_events_constraints.sql. Adjust the existing table definition in packages/db/07_label_events.sql instead — append the unique index next to the existing index:

CREATE UNIQUE INDEX IF NOT EXISTS uq_label_events_natural_key
    ON label_events (repo_full_name, target_number, target_type,
                     label_name, action, timestamp)
    NULLS NOT DISTINCT;

Any one-time migration SQL (e.g. the dedupe DELETE) should go in the PR description, not a dedicated .sql file.

TypeScript changes are fine as-is.

@hunnyboy1217

Copy link
Copy Markdown
Contributor Author

Please remove packages/db/11_label_events_dedup.sql and packages/db/12_label_events_constraints.sql. Adjust the existing table definition in packages/db/07_label_events.sql instead — append the unique index next to the existing index:

CREATE UNIQUE INDEX IF NOT EXISTS uq_label_events_natural_key
    ON label_events (repo_full_name, target_number, target_type,
                     label_name, action, timestamp)
    NULLS NOT DISTINCT;

Any one-time migration SQL (e.g. the dedupe DELETE) should go in the PR description, not a dedicated .sql file.

TypeScript changes are fine as-is.

I'll fix it asap.

@hunnyboy1217

Copy link
Copy Markdown
Contributor Author

Hello, @anderdc.
I have just completed the update exactly as you instructed.

@hunnyboy1217
hunnyboy1217 requested a review from anderdc May 11, 2026 19:21
…abel-events

# Conflicts:
#	packages/das/src/webhook/github-fetcher.service.ts
#	packages/das/src/webhook/handlers/label.handler.ts
@anderdc anderdc mentioned this pull request May 13, 2026
8 tasks

@anderdc anderdc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolves all prior review items verbatim; runbook in body matches the prescribed shape.

@hunnyboy1217

Copy link
Copy Markdown
Contributor Author

Thanks, @anderdc .

@anderdc
anderdc merged commit f5b11c1 into entrius:test May 13, 2026
2 checks passed
@anderdc anderdc mentioned this pull request May 14, 2026
4 tasks
anderdc pushed a commit that referenced this pull request May 14, 2026
* Make label_events writes idempotent on backfill (#25)

* Move unique index into 07_label_events.sql, drop migration files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Non-idempotent label event insertion duplicates rows on every backfill

2 participants