Skip to content

fix(pesacheck_meedan_bridge): Stop retrying fact-checks Check already has - #1210

Merged
koechkevin merged 3 commits into
mainfrom
fix/pesacheck-duplicate-factchecks
Sep 30, 2026
Merged

koechkevin merged 3 commits into
mainfrom
fix/pesacheck-duplicate-factchecks

Conversation

@koechkevin

@koechkevin koechkevin commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Why

Check refuses a fact-check whose content it already has:

PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_fact_checks_on_signature"

The bridge treated that as an ordinary failure: the row stayed Pending, so it was retried on every run, failed again, and reported one Sentry exception each time. The set only ever grows. Posting 50 articles to the sandbox this week left 33 rows retrying forever, which is 33 wasted Check calls and 33 Sentry errors per run, drowning out real failures.

What changed

  • post_to_check() raises a dedicated DuplicateFactCheckError when a GraphQL error mentions index_fact_checks_on_signature. Any other GraphQL error keeps the existing behaviour.
  • post_to_check_and_update() marks such a row Duplicate, a terminal status. Pending rows left over from earlier runs resolve to Duplicate the next time they are attempted.
  • Duplicates are no longer reported as exceptions. They are summarised once in the end-of-run message: Posted 0 PesaCheck article(s) to Check: []. Skipped 33 article(s) Check already has: [...].
  • README: Duplicate added to the status table, noting the Check ids aren't recorded, so the article has to be found by title if they're needed.
  • VERSION → 0.1.22, so merging deploys it.

Testing

  • Unit tests (35, stdlib unittest, run locally): 5 new — a duplicate is marked terminally and not retried, duplicates are summarised rather than reported as errors, a leftover Pending duplicate resolves, post_to_check() raises the new error for the signature violation, and other GraphQL errors do not become duplicates.
  • Against the live pesacheck-tipline-sandbox workspace, using the database from the 50-article batch that had 33 stuck rows:
    • First run: all 33 became Duplicate in 22s, 0 exceptions, one summary line.
    • Second run: 0 Check API calls, finished in 0.9s.
  • flake8 and ruff format pass.

Notes

A Duplicate row stores no check_project_media_id/check_full_url, because the mutation returns nothing on rejection. If we want those recorded, a follow-up could look the item up in Check by URL and backfill the ids.

… has

Check rejects a repeat of the same fact-check with a PG::UniqueViolation
on index_fact_checks_on_signature. The row stayed Pending, so every run
retried it and failed again: a 50-article batch against the sandbox left
33 rows retrying forever, one Sentry exception each.

post_to_check() now raises DuplicateFactCheckError for that constraint,
and the row is marked Duplicate, which is terminal. Duplicates are
summarised once in the end-of-run message instead of being reported as
exceptions.
@koechkevin

Copy link
Copy Markdown
Contributor Author

@claude review

@koechkevin koechkevin self-assigned this Sep 28, 2026
@koechkevin
koechkevin requested a review from a team September 28, 2026 10:21
@kilemensi

kilemensi commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Check with codex also @koechkevin :

[P2] Atomically claim pending rows before posting — main.py:157

Overlapping runs can both load the same Pending row and both set it to Posting. If one request succeeds while the other receives the duplicate response, the duplicate handler can run last and overwrite Completed with terminal Duplicate. The row then retains Check IDs while being reported as an unrecorded duplicate. Claim the row with a compare-and-set transition such as UPDATE ... WHERE status = 'Pending', and post only when exactly one row was updated.

Test coverage

Test coverage is also absent from the PR: its description mentions 35 local tests and five new cases, but no test files are committed, so CI cannot preserve this incident fix or cover the concurrency case above.


Fix it by making Pending → Posting an atomic compare-and-set. Only the process that successfully changes the row may call Check.

In database.py:

def claim_pending_feed(self, guid):
    conn = self.create_connection()
    try:
        cur = conn.cursor()
        cur.execute(
            """
            UPDATE pesacheck_feeds
            SET status = 'Posting'
            WHERE guid = ? AND status = 'Pending'
            """,
            (guid,),
        )
        conn.commit()
        return cur.rowcount == 1
    finally:
        conn.close()

Then in post_to_check_and_update():

def post_to_check_and_update(feed, db):
    input_data = build_check_input(feed)

    # Another run may have claimed this feed after we loaded the Pending list.
    if not db.claim_pending_feed(feed.guid):
        return None

    try:
        res = post_to_check(input_data)
    except DuplicateFactCheckError:
        db.update_pesacheck_feed_status(feed.guid, "Duplicate")
        raise
    # ...

Both callers must avoid adding None to success_posts:

posted = post_to_check_and_update(pending, db=db)
if posted is not None:
    success_posts.append(posted)

Why this works:

  • Two runs may both read the row as Pending.
  • SQLite serializes their conditional updates.
  • The first update changes Pending to Posting and returns True.
  • The second update matches zero rows and returns False.
  • Only the winner sends the external API request.

I would also make terminal transitions conditional on status = 'Posting', preventing an operator or another recovery path from being overwritten:

UPDATE pesacheck_feeds
SET status = ?
WHERE guid = ? AND status = 'Posting'

Tests should cover two claims against the same row, verify only one succeeds, and confirm a Completed row can never be downgraded to Duplicate.

Comment thread pesacheck_meedan_bridge/py/check_api.py Outdated
Comment thread pesacheck_meedan_bridge/py/check_api.py
Comment thread pesacheck_meedan_bridge/py/main.py Outdated
Comment thread pesacheck_meedan_bridge/py/check_api.py Outdated
Comment thread pesacheck_meedan_bridge/py/check_api.py Outdated
Comment thread pesacheck_meedan_bridge/py/main.py Outdated
Comment thread pesacheck_meedan_bridge/py/main.py
Comment thread pesacheck_meedan_bridge/py/main.py
Comment thread pesacheck_meedan_bridge/py/main.py Outdated
…ests

Review feedback on #1210.

- Treat a response as a duplicate only when EVERY GraphQL error is the
  signature violation; a second, unrelated error no longer marks the row
  terminally and swallows the real problem
- Tolerate odd error shapes (non-dict entries, an explicit null message)
  instead of raising AttributeError over the response body
- Claim a row atomically (Pending -> Posting, compare-and-set) so two
  overlapping runs can't both post it, and make terminal transitions
  conditional on Posting so a finished row can't be downgraded
- Keep the in-memory feed.status in step with the row, and don't let a
  failure while recording "Duplicate" replace the duplicate outcome
- One post_and_record() for both call sites, so a future one can't
  silently opt out of duplicate handling
- Commit the test suite (42 cases) as a python_tests target, plus a CI
  workflow that lints, tests and builds the bridge on pull requests
B105 reads the test-only PESACHECK_CHECK_TOKEN value as a credential.
@kilemensi

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-29T16:37:47.144154Z 02bcb60 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 02bcb60a6a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@kilemensi

Copy link
Copy Markdown
Member

@claude review

@kilemensi kilemensi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM!

@koechkevin
koechkevin merged commit 41f97ce into main Sep 30, 2026
5 checks passed
@koechkevin
koechkevin deleted the fix/pesacheck-duplicate-factchecks branch September 30, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants