Skip to content

🐛 fix(lqa): stop qa_chunk_reviews penalty_points drifting from qa_entries - #4690

Open
mauretto78 wants to merge 2 commits into
developfrom
fix-qa-chunk-review-penalty-points-drift
Open

🐛 fix(lqa): stop qa_chunk_reviews penalty_points drifting from qa_entries#4690
mauretto78 wants to merge 2 commits into
developfrom
fix-qa-chunk-review-penalty-points-drift

Conversation

@mauretto78

@mauretto78 mauretto78 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

qa_chunk_reviews.penalty_points was drifting away from the live
SUM(qa_entries.penalty_points) in production only. Root-cause analysis found
two production-only application-level causes plus two related weaknesses,
fixed together with new detection/repair tooling.

Type

  • feat — new user-facing feature
  • fix — bug fix
  • refactor — restructure without behavior change
  • chore — build, deps, config, docs
  • perf — performance improvement
  • test — test coverage

Changes

File Change
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php Removed the stale-read guard that silently skipped the penalty_points decrement on delete; now always subtracts and lets the DAO's atomic GREATEST(...,0) clamp at zero. Wraps save/delete/editFrom in the new per-job lock.
lib/Model/LQA/ChunkReviewDao.php passFailCountsAtomicUpdate() no longer early-returns (and writes nothing) when a project has no LQA model — counters always write, only the is_pass clause is conditional. Added destroyCachesFor() and findPenaltyPointsMismatches().
lib/Utils/LQA/ChunkReviewJobLock.php New best-effort per-job Redis lock (built on the previously-unused RedisHandler::tryLock/unlock) serializing the single-issue write path against job split/merge. Fails open on Redis errors/timeouts.
lib/Plugins/Features/AbstractRevisionFeature.php postJobSplitted()/postJobMerged() now use ChunkReviewJobLock; alterChunkReviewStruct() now busts caches after its write.
lib/Plugins/Features/ReviewExtended/ChunkReviewModel.php Both write paths (_updatePassFailResult, recountAndUpdatePassFailResult) now bust caches after writing.
lib/Model/QualityReport/QualityReportModel.php updateChunkReview() now busts caches after writing (used by resetScore()).
lib/Plugins/Features/ReviewExtended/Email/PenaltyPointsDriftAlertEmail.php, lib/View/Emails/ReviewExtended/penalty_points_drift_alert.html New alert email for the drift-detection CLI task in the companion internal_scripts PR.
internal_scripts (submodule bump) Picks up revision:check-penalty-drift and revision:recount-drifted from matecat/internal_scripts#46.

Testing

  • vendor/bin/phpunit --exclude-group=ExternalServices --no-coverage passes
  • ./vendor/bin/phpstan passes (0 errors, with baseline)
  • Manual testing performed (describe below)
  • New tests added for changed behavior
  • Regression tests added for bug fixes

239 tests / 904 assertions passing across the touched suites
(TranslationIssueModelTest, ChunkReviewDaoTest, ChunkReviewDaoRealSqlTest,
AbstractRevisionFeatureTest, ChunkReviewModelTest, QualityReportModelTest,
new ChunkReviewJobLockTest). PHPStan clean (0 errors) on every changed file.
Companion submodule PR (matecat/internal_scripts#46) has its own 34/34 passing
suite for the two new CLI tasks.

AI Disclosure

  • No AI tools were used in this PR
  • AI tools were used — name the agent/tool below

Claude Code (claude-sonnet-5)

Notes

Depends on matecat/internal_scripts#46 — merge that first (already reflected
by this PR's submodule pointer bump commit).

This fixes the code paths that cause future drift; it does not repair
qa_chunk_reviews rows that already drifted in production. Once merged, run
revision:check-penalty-drift to find currently-affected jobs and
revision:recount-drifted --live to repair them (dry-run by default).


Picks up revision:check-penalty-drift and revision:recount-drifted
(matecat/internal_scripts#46), needed by the qa_chunk_reviews
penalty_points drift fix in this PR.
…ries

qa_chunk_reviews.penalty_points was drifting from the live sum of
qa_entries.penalty_points in production. Four contributing causes,
ranked by probability during investigation:

- TranslationIssueModel::delete() pre-checked a stale, non-locking read
  of the current total before deciding whether to subtract, silently
  skipping the decrement when it looked like it would go negative,
  while the qa_entries row was already unconditionally soft-deleted.
  The DAO's atomic GREATEST(...,0) clamp already handles this safely,
  so the guard is removed and the subtract now always runs.
- ChunkReviewDao::passFailCountsAtomicUpdate() returned early — writing
  nothing at all — whenever a project had no LQA model, permanently
  freezing penalty_points/reviewed_words_count/total_tte. Counters now
  always write; only the is_pass clause (which needs the model's
  force_pass_at threshold) is skipped without one.
- Job split/merge deletes and recreates qa_chunk_reviews rows with no
  locking, racing against concurrent single-issue add/edit/delete on
  the same job. Both paths now serialize per job_id via a new
  best-effort Redis lock (Utils\LQA\ChunkReviewJobLock, built on the
  previously-unused RedisHandler::tryLock/unlock) — it fails open on
  Redis errors/timeouts so this can never make core review
  functionality hard-depend on Redis availability.
- None of the counter/pass-fail write paths invalidated the Redis
  caches on findChunkReviews/findByProjectId/
  findByJobIdReviewPasswordAndSourcePage, so pages could show a stale
  score/pass-fail badge after a correct write. All four write paths
  now call the new ChunkReviewDao::destroyCachesFor().

Also adds ChunkReviewDao::findPenaltyPointsMismatches(), the shared
detection query behind the two new CLI tasks in the internal_scripts
submodule (revision:check-penalty-drift, revision:recount-drifted).
@github-actions

Copy link
Copy Markdown

🧪 Test-Guard Report

⚠️ WARNING — Test coverage has minor gaps — review recommended.

Coverage Analysis: ❌ FAIL

Changed lines: 59.0% covered (threshold: 80%)

📋 7 files: 3 ❌ fail, 4 ✅ pass
File Verdict Reason
lib/Model/LQA/ChunkReviewDao.php ✅ pass 100% diff coverage ≥ 80% threshold
lib/Model/QualityReport/QualityReportModel.php ❌ fail 0% diff coverage < 80% threshold
lib/Plugins/Features/AbstractRevisionFeature.php ❌ fail 20% diff coverage < 80% threshold
lib/Plugins/Features/ReviewExtended/ChunkReviewModel.php ✅ pass 100% diff coverage ≥ 80% threshold
lib/Plugins/Features/ReviewExtended/Email/PenaltyPointsDriftAlertEmail.php ❌ fail 0% diff coverage < 80% threshold
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php ✅ pass 100% diff coverage ≥ 80% threshold
lib/Utils/LQA/ChunkReviewJobLock.php ✅ pass 100% diff coverage ≥ 80% threshold

Test File Matching: ❌ FAIL

File matching: 3 pass, 3 warning, 1 fail

📋 7 files: 1 ❌ fail, 3 ⚠️ warning, 3 ✅ pass
File Verdict Reason
lib/Model/LQA/ChunkReviewDao.php ✅ pass Test file modified in PR: tests/unit/Core/DAO/TestChunkReviewDAO/ChunkReviewDaoTest.php
lib/Model/QualityReport/QualityReportModel.php ⚠️ warning Test file exists (tests/unit/Core/Model/QualityReport/QualityReportModelTest.php) but was not modified in this PR
lib/Plugins/Features/AbstractRevisionFeature.php ⚠️ warning Test file exists (tests/unit/Core/Features/AbstractRevisionFeatureTest.php) but was not modified in this PR
lib/Plugins/Features/ReviewExtended/ChunkReviewModel.php ⚠️ warning Test file exists (tests/unit/Core/Plugins/Features/ReviewExtended/ChunkReviewModelTest.php) but was not modified in this PR
lib/Plugins/Features/ReviewExtended/Email/PenaltyPointsDriftAlertEmail.php ❌ fail No matching test file found
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php ✅ pass Test file modified in PR: tests/unit/Core/Plugins/Features/ReviewExtended/TranslationIssueModelTest.php
lib/Utils/LQA/ChunkReviewJobLock.php ✅ pass Test file modified in PR: tests/unit/Core/Utils/LQA/ChunkReviewJobLockTest.php

Per-File Evaluation: ⚠️ WARNING

Evaluated 7 files: 3 via AI (1 batch), 4 via shortcuts.

📋 7 files: 3 ⚠️ warning, 4 ✅ pass
File Verdict Reason
lib/Model/LQA/ChunkReviewDao.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Plugins/Features/ReviewExtended/ChunkReviewModel.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Utils/LQA/ChunkReviewJobLock.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Model/QualityReport/QualityReportModel.php ⚠️ warning Update calls destroyCachesFor to cover cache invalidation; coverage is 0%.
lib/Plugins/Features/AbstractRevisionFeature.php ⚠️ warning Test coverage is partial (20%) and covers only some code paths, especially around locking and review processing.
lib/Plugins/Features/ReviewExtended/Email/PenaltyPointsDriftAlertEmail.php ⚠️ warning This is a new class with no existing tests; coverage is 0%.

Result: ⚠️ WARNING


Why this WARNING?

  • Coverage: lib/Model/QualityReport/QualityReportModel.php has 0% coverage; update calls to destroyCachesFor are missing, leading to cache invalidation issues.
  • Coverage: lib/Plugins/Features/AbstractRevisionFeature.php has only 20% coverage, mainly around locking and review processing; more comprehensive tests are needed.
  • Test Matching: lib/Plugins/Features/ReviewExtended/PenaltyPointsDriftAlertEmail.php has no matching test file; this class is new and requires test coverage.
  • File Matching: Several files (e.g., QualityReportModel.php, AbstractRevisionFeature.php, ChunkReviewModel.php) have existing tests but were not modified in this PR, leading to warnings.

To resolve: add tests for lib/Plugins/Features/ReviewExtended/PenaltyPointsDriftAlertEmail.php and increase coverage for QualityReportModel.php and AbstractRevisionFeature.php.

@mauretto78
mauretto78 requested a review from Ostico July 20, 2026 15:13
@mauretto78

Copy link
Copy Markdown
Contributor Author

@Ostico could you take a look at this one when you have a chance?

Summary: qa_chunk_reviews.penalty_points was drifting from the live SUM(qa_entries.penalty_points) in production only (never reproducible locally). Root-cause analysis turned up two application-level causes plus two related weaknesses, all fixed here:

  1. Stale-read guard in TranslationIssueModel::delete() — it pre-checked a non-locking read of the current total before deciding whether to subtract, and silently skipped the decrement if it looked like it would go negative, even though the qa_entries row was already unconditionally soft-deleted. The DAO's atomic GREATEST(...,0) clamp already handles the negative case safely, so the guard was just wrong — removed it, subtract always runs now.
  2. Silent no-op in ChunkReviewDao::passFailCountsAtomicUpdate() — it returned early (writing nothing) whenever a project had no LQA model, permanently freezing the counters. Counters now always write; only the is_pass clause (which needs the model's threshold) is skipped without one.
  3. Unlocked race between job split/merge (delete+recreate+recompute) and concurrent single-issue add/edit/delete on the same job — added a best-effort per-job Redis lock (Utils\LQA\ChunkReviewJobLock) on both sides. It fails open on Redis errors/timeouts, so it can't turn into a new availability risk.
  4. Missing cache invalidation — none of the counter/pass-fail write paths busted the Redis caches on the chunk-review read methods, so pages could show a stale score/pass-fail badge even after a correct write. All write paths now call the new ChunkReviewDao::destroyCachesFor().

Also added ChunkReviewDao::findPenaltyPointsMismatches() plus two companion CLI tasks in matecat/internal_scripts#46 (revision:check-penalty-drift for detection/alerting, revision:recount-drifted for batch repair) — this PR fixes future drift, it doesn't repair rows that already drifted in prod.

239 tests / 904 assertions passing, PHPStan clean on every changed file. Full analysis + design tradeoffs are in the PR description. Thanks in advance!

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.

1 participant