Skip to content

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

Open
mauretto78 wants to merge 4 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 4 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!

@Ostico Ostico left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary

The diagnosis behind this PR is right, and several parts of the fix are genuinely good. Three of the
sub-fixes are correct and I mutation-checked each one (restoring the old behaviour makes tests fail,
so they are held by the suite rather than passing by accident):

  • Removing the lqaModel === null early return in passFailCountsAtomicUpdate(). A project with no
    LQA model previously skipped the entire counter update, so penalty_points never accumulated at
    all. That was a real drift source.
  • Removing the stale-read guard in TranslationIssueModel::delete(). The old guard decided whether
    to decrement based on an in-memory struct value read outside any lock, and silently skipped the
    decrement when it looked wrong.
  • destroyCachesFor(). None of the counter write paths busted the cached reads before, so the UI
    could show a stale score for up to an hour.

The ON DUPLICATE KEY UPDATE delta statement itself is sound — a single self-referential statement
under the row lock is the correct shape for the increment/decrement path.

Full suite on this branch is green: OK (9217 tests, 30036 assertions).

That said, I do not think the concurrency fix holds up, and I found one arithmetic bug that stops
the new repair command from ever converging. Details and suggested fixes below.


Blocking

1. The lock is released before the transaction commits, so it does not serialize anything

This is the main one. ChunkReviewJobLock::run() releases the lock in its finally block, and every
call site sits inside an already-open transaction:

Path Transaction Lock taken
create issue SegmentTranslationIssueController.php begin():81commit():93 inside save():91
update issue begin():123commit():196 inside delete():174 and again inside save():183
delete issue begin():214commit():232 inside delete():231
merge JobSplitMergeService.php dispatch :635commit():641 inside the event handler

So the sequence is: acquire → write → release → commit. A second process can take the lock the
instant our callback returns, while our writes are still uncommitted. It then reads a state that does
not include our change and writes an absolute value on top of it after we commit. That is exactly the
lost update this PR is trying to eliminate.

The update path is the clearest case: the decrement (delete()) and the increment (save()) take
and release the lock separately, so they are never under a single hold. Another writer can interleave
between them.

Worth double-checking the split path too — I could not confirm from the line numbers whether
postJobSplitted dispatch is inside the same transaction as the merge one is, so please verify.

Remediation. The lock has to wrap the transaction, not the other way round:

// SegmentTranslationIssueController::update()
ChunkReviewJobLock::run($idJob, function () {
    $this->getDatabase()->begin();
    try {
        $model->delete();
        $struct = $model->save();
        $this->getDatabase()->commit();
    } catch (Throwable $e) {
        $this->getDatabase()->rollback();
        throw $e;
    }

    return $struct;
});

That gives one hold spanning both the decrement and the increment, and the lock is only released once
the work is visible to everyone else.

2. getPenaltyPointsForChunk() returns int and truncates the sum

lib/Model/LQA/ChunkReviewDao.php:144:

public function getPenaltyPointsForChunk(JobStruct $chunk, ?int $source_page = null): int

But the column is double(20, 2) (INSTALL/matecat.sql:882 and :922), and
EntryStruct::$penalty_points is ?float. There is no declare(strict_types=1) in the file, so
"7.50" silently becomes int(7).

This matters more than a rounding nit, because of where the value lands:

  • ChunkReviewModel::recountAndUpdatePassFailResult() writes it as an absolute value — so the
    recount actively corrupts a row that was previously correct.
  • That recount is what revision:recount-drifted --live runs.
  • The new detector compares ROUND(actual, 2) != ROUND(recorded, 2).

Net result on any chunk with fractional penalty points: the repair writes 7 where the truth is
7.50, the detector immediately flags the same row again, and the alert email reports it on every
run forever. The two new commands work against each other.

The same truncation exists at ReviewedWordCountModel.php:435
(getPenaltyPointsForSourcePage(): int), which is on the segment-status-change path.

Remediation. Return float from both, and cast explicitly rather than relying on coercion:

public function getPenaltyPointsForChunk(JobStruct $chunk, ?int $source_page = null): float
{
    // ...
    return (float)($count[0] ?? 0);
}

Please add a real-SQL test with fractional penalties (two entries of 2.75, say) asserting that the
recount and the detector agree afterwards. Without one this will regress quietly.

3. The lock primitive is not safe enough for a data-integrity guarantee

ChunkReviewJobLock is the only caller of RedisHandler::tryLock() — it was unused before this PR,
so it is worth looking at closely now that it is load-bearing.

The TTL is the wait budget, not a lease. tryLock($key, $wait_time_seconds) sets the key's expiry
to the same value it uses as the acquisition timeout. ChunkReviewJobLock defaults to 5s, and
split/merge passes 10s. So the lock expires 5 or 10 seconds after acquisition regardless of how long
the work takes. postJobSplitted/postJobMerged do deleteByJobId plus N × createRecord plus
N × recountAndUpdatePassFailResult (three aggregate queries each). Going past 10s on a large job
under load is entirely plausible, at which point a second process enters the critical section and
nothing anywhere reports it.

Acquisition is not atomic. setnx and expire are two separate round trips. If the process dies
between them — fatal error, OOM, php-fpm timeout, deploy restart — the key is left with no TTL,
and unlock() only deletes on an identifier match that no future process will have. From then on
every issue operation on that job burns its full 5–10s wait, logs, and proceeds unlocked. Permanently,
and silently. That is worse than having no lock at all.

Remediation. Use one atomic command, and separate the lease from the wait:

// acquire: one round trip, TTL set atomically
$acquired = $conn->set($key, $identifier, 'EX', $leaseSeconds, 'NX');

and give tryLock() a separate $leaseSeconds parameter sized to the work (say 30s for issue CRUD,
120s for split/merge) rather than reusing the caller's wait budget. unlock() should also be a Lua
compare-and-delete rather than GET then DEL, which can delete a successor's lock if the TTL lapsed
in between.

4. Several writers of the same rows never take the lock

ChunkReviewJobLock::run has exactly five call sites. These write qa_chunk_reviews.penalty_points
and are not among them:

  • BatchReviewProcessor.php:150 — delta, on the segment approve/reject path (the highest-volume
    writer in the product)
  • BatchReviewProcessor.php:119 — absolute recount
  • AbstractRevisionFeature.php:358QualityReportModel::resetScore() — absolute, sets the row to 0
  • AbstractRevisionFeature.php:367 alterChunkReviewStruct() — absolute, restores from undo_data
  • internal_scripts FixChunkReviewPenaltyPointsDrift.php:126 — absolute recount

All the absolute ones are read-modify-write, so a delta landing between their read and their write is
lost. A lock that only two of the writer families take does not establish mutual exclusion — a
split/merge can still run against rows that BatchReviewProcessor is mid-transaction on.

The last one deserves special attention: the repair command is itself an unlocked lost-update
writer
, and it also opens no transaction, so under ProxySQL its SELECTs are replica-routed while
its UPDATE goes to the primary. Run --live against live traffic and it can read a lagging replica
sum and write it authoritatively over a newer delta. The tool built to fix drift can introduce it.

Remediation. Either bring these paths under the same per-job lock, or scope the claim down and
lean on the atomic SQL plus the detector. Either way, the repair command needs the lock and needs its
reads pinned to the primary (wrapping it in a transaction is enough for ProxySQL persistence). The
docblock at ChunkReviewJobLock.php:10-13 currently describes coverage the code does not have.

5. The INSERT branch has no GREATEST clamp

In passFailCountsAtomicUpdate() the clamp only exists in the ON DUPLICATE KEY UPDATE clause. The
VALUES(...) list binds :penalty_points raw. If the row is absent when the statement lands — which
the deleteByJobId + recreate window in split/merge makes reachable — a subtract inserts a row with a
negative penalty_points.

Remediation. Clamp on insert too:

VALUES( :id, :id_job, :id_project, :password, :review_password,
        GREATEST( :penalty_points, 0 ),
        GREATEST( :reviewed_words_count, 0 ),
        GREATEST( :total_tte, 0 ) )

Related: save() and editFrom() use the ChunkReviewStruct loaded in the constructor, outside the
lock, while delete() re-reads inside it. Worth making those consistent.

6. The alert email tells operators to run a flag that does not exist

lib/View/Emails/ReviewExtended/penalty_points_drift_alert.html:27 says:

Run revision:recount-drifted --dry-run to review, or without --dry-run

FixChunkReviewPenaltyPointsDrift defines only two options, min-job-id and live. Symfony Console
will error on the undefined --dry-run, and the polarity is inverted anyway — running with no flag is
already the dry run, and --live is what performs the repair.

Remediation. Change the text to revision:recount-drifted to review and
revision:recount-drifted --live to apply.


Non-blocking, but worth addressing

The clamp hides the problem instead of reporting it. GREATEST(x, 0) discards the surplus with
nothing logged. When a decrement lands before its matching increment the row floors at 0 and the
difference is gone permanently — only a full recount fixes it, and per issue 2 the recount currently
truncates. On a data-integrity path this should be observable when it happens, not inferred from the
nightly email.

Fail-open cannot distinguish "Redis is down" from "another writer holds the lock." Both produce the
same non-error log line at ChunkReviewJobLock.php:35. The second case is the single most valuable
signal this class could emit — it means we are about to run unprotected, concurrently, right now. The
fail-open policy itself is defensible; it just needs to be visible. Consider logging at error level
with the exception class and elapsed wait, plus a caller-supplied context label.

The detection query is unbounded. findPenaltyPointsMismatches() joins qa_chunk_reviews to
jobs to qa_entries on e.id_segment BETWEEN j.job_first_segment AND j.job_last_segment — a range
join — then groups over the whole result with no LIMIT. With the default $minJobId = null this
scans every chunk review ever created, and the full result set is rendered one row per <tr> into an
email. Please EXPLAIN it against production-sized data before it runs on a schedule, and cap the
email body.

destroyCachesFor() runs inside the open transaction with no re-bust after commit. The window
between the DEL and the COMMIT is exactly when a concurrent reader can repopulate the 1-hour-TTL
caches with the pre-write value, which then sticks for the full hour. Busting after commit would be
safer.


Test coverage

The changed-line coverage gate reports 59% against an 80% threshold, with QualityReportModel at 0%,
AbstractRevisionFeature at 20% (139 changed lines) and PenaltyPointsDriftAlertEmail at 0%. The
drift CLI tests do exist, but they live in the internal_scripts submodule where the gate cannot see
them — worth noting in the PR description so it does not read as untested.

Both repos have to deploy together, since internal_scripts holds the only consumers of
findPenaltyPointsMismatches() and PenaltyPointsDriftAlertEmail.

The new ChunkReviewJobLockTest cases are meaningful — they would fail if the class were deleted —
but they only cover the single-threaded path. Nothing asserts that a second caller is actually
excluded while the first is inside the callback, which is the one property the class exists for, and
nothing asserts the lock outlives a long critical section. A test with a 2s callback under
waitSeconds = 1 that then checks the key still exists would have caught the TTL issue. The
wall-clock timing assertions will also likely flake on loaded CI.

Also worth a second look: the PR description says 239 tests / 904 assertions across the touched
suites; running the ones I could identify sums to 175 / 677. Probably the submodule tests making up
the difference, but worth confirming.


Suggested direction

Drop the Redis lock entirely rather than repair it — SELECT ... FOR UPDATE inside the transaction already there gives real mutual exclusion, commit-aligned release, and no fail-open mode, so the whole TTL/atomicity/coverage class of problems disappears instead of being patched.

Incremental deltas plus a best-effort external lock is a difficult shape for this invariant given
ProxySQL, the transaction boundaries above, and the number of writers spread across web, worker and
CLI. Two alternatives that would remove most of the blocking issues outright:

  1. Make penalty_points derived. Recompute in a single self-contained statement at each write
    boundary:

    UPDATE qa_chunk_reviews r
    SET r.penalty_points = (
        SELECT COALESCE(SUM(e.penalty_points), 0)
        FROM qa_entries e
        JOIN jobs j ON j.id = r.id_job AND j.password = r.password
        WHERE e.id_job = j.id
          AND e.id_segment BETWEEN j.job_first_segment AND j.job_last_segment
          AND e.source_page = r.source_page
          AND e.deleted_at IS NULL
    )
    WHERE r.id = :id

    Atomic, no lock, no clamp, and no PHP-side float handling to get wrong.

  2. Keep the deltas and serialize on the row itself with SELECT ... FOR UPDATE on the
    qa_chunk_reviews row inside the existing transaction. Real mutual exclusion, released exactly at
    commit, and no fail-open mode.

Happy to talk either through if useful.

…-drift

Resolve conflicts between the penalty-points drift fix and develop's acting-user threading
(d90bb2d), which made UserStruct $actingUser a required argument on ChunkReviewModel's
recount and penalty-point methods.

- AbstractRevisionFeature: keep the ChunkReviewJobLock wrapper and pass $event->actingUser,
  capturing $event in both closures so it is in scope
- TranslationIssueModel: thread $this->actingUser through all three call sites; keep the
  removal of the protected subtractPenaltyPoints helper, whose >= 0 guard is the drift this
  branch fixes
- internal_scripts: bump to the matching master merge, which gives revision:recount-drifted
  the same required uid argument develop added to revision:recount
@github-actions

github-actions Bot commented Aug 6, 2026

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 Missing tests for the added cache invalidation logic.
lib/Plugins/Features/AbstractRevisionFeature.php ⚠️ warning Critical job locking logic wrap and cache invalidation lack specific test coverage.
lib/Plugins/Features/ReviewExtended/Email/PenaltyPointsDriftAlertEmail.php ⚠️ warning New email class has no corresponding test coverage for generation or sending.

Result: ⚠️ WARNING


Why this WARNING?

  • Coverage Analysis: QualityReportModel.php, AbstractRevisionFeature.php, and PenaltyPointsDriftAlertEmail.php failed to meet the 80% threshold for changed lines → Action required: add unit tests for the new logic in these files.
  • Test File Matching: PenaltyPointsDriftAlertEmail.php lacks a corresponding test file, while QualityReportModel.php and AbstractRevisionFeature.php have existing tests that were not updated for the new changes → Action required: create a new test for the email class and update existing tests for the models.
  • AI Evaluation: Identified missing test coverage for cache invalidation logic in QualityReportModel.php, job locking logic in AbstractRevisionFeature.php, and email generation/sending in PenaltyPointsDriftAlertEmail.php → Action required: implement specific test cases for these identified gaps.

To resolve: Add or update unit tests to cover the new logic in QualityReportModel.php, AbstractRevisionFeature.php, and PenaltyPointsDriftAlertEmail.php.

…ting penalty points

Addresses the review on #4690.

Replace the Redis advisory lock with SELECT ... FOR UPDATE on the job's qa_chunk_reviews rows.
The old lock released in a `finally` while every caller's transaction was still open, so a second
process could enter the critical section, read state without our uncommitted change, and write an
absolute value over it — the lost update the lock was meant to prevent. The update path was worse:
delete() and save() took and released it separately, so they never spanned one hold.

- ChunkReviewDao::lockByJobId() locks by id_job, not row id: split/merge deletes and recreates the
  rows, so no stable row exists. Under REPEATABLE READ the id_job index range also gap-locks, which
  closes the delete/recreate window. Throws outside a transaction, where FOR UPDATE would take the
  locks and drop them again immediately.
- Taken inside ChunkReviewModel's recount and delta paths rather than at each call site, so the
  previously unlocked writers — BatchReviewProcessor, split/merge, the repair CLI — are covered.
  resetScore() and alterChunkReviewStruct() lock explicitly as they bypass ChunkReviewModel.
- Delete ChunkReviewJobLock and its test. RedisHandler::tryLock() is unused again; document its
  TTL-as-wait-budget and non-atomic setnx+expire defects so it is not adopted as-is.

Return float from getPenaltyPointsForChunk() and getPenaltyPointsForSourcePage(). penalty_points is
double(20,2) and PDO returns SUM() as a string, so an int return silently made "7.50" into 7. The
recount writes that back as an absolute while the detector compares to 2dp, so the repair corrupted
correct rows and re-flagged them forever.

Clamp the INSERT branch of passFailCountsAtomicUpdate, reachable for a subtract via the split/merge
recreate window. The deltas are bound separately as :*_delta rather than read back with
VALUES(penalty_points): VALUES() yields the value that would have been inserted, so clamping the
list would turn every decrement into GREATEST(-3,0) = 0 and silently stop all subtraction.

Correct the drift alert email, which named a --dry-run flag that does not exist, inverted the
polarity, and omitted the now-required uid argument.
@mauretto78

Copy link
Copy Markdown
Contributor Author

Thanks — this was a genuinely useful review. I checked all six blocking points against the code and they all hold. Pushed in ba2457c (plus 06b261a9 in internal_scripts).

Heads-up: remediation 5 as written breaks every decrement

Worth flagging before anyone applies it elsewhere. Clamping the VALUES list works for the insert, but the ON DUPLICATE KEY UPDATE clauses read the delta back with VALUES(penalty_points) — which MySQL defines as the value that would have been inserted, i.e. the clamped expression. A delta of -3 becomes GREATEST(-3, 0) = 0, so the update adds nothing.

I applied your version verbatim first to check, and the decrement is a total no-op:

passFailCountsAtomicUpdate_still_applies_a_negative_delta_on_update
the decrement must actually apply
Failed asserting that 10.5 is identical to 7.0.

So the insert clamp is in, but the deltas are bound a second time under their own :*_delta names — insert clamps, update keeps the signed delta. Both branches now have real-SQL tests, and the second one is specifically the guard against reintroducing this.

The split path you asked me to verify

Confirmed defective, same as merge. JobSplitMergeService::applySplit does beginTransaction()splitJob() (which dispatches PostJobSplittedEvent at :555) → commit(), so postJobSplitted held the lock inside the transaction exactly like postJobMerged.

Took your suggested direction

Dropped the Redis lock rather than repairing it. ChunkReviewDao::lockByJobId() does SELECT id … WHERE id_job = ? ORDER BY id FOR UPDATE.

Two things worth calling out:

  • Locks by id_job, not row id. Split/merge deletes and recreates the rows, so there's no stable row to lock. Under REPEATABLE READ the id_job index range also gap-locks, which is what closes the delete→recreate window rather than just protecting rows that already exist. Checked that KEY id_job exists so this is a range lock and not a table lock.
  • It throws outside a transaction. FOR UPDATE under autocommit acquires and drops the locks before the caller does its work — the same silent-no-protection failure as the old lock, so I'd rather it be loud. I verified all seven write entry points already open a transaction: SegmentTranslationIssueController, CompletionEventController, BulkSegmentStatusChangeWorker, CopyAllSourceToTargetController, SetTranslationController (via TranslationVersionsHandler), applySplit/mergeALL, and the repair CLI.

On your point 4: rather than patching each call site, the lock is taken inside ChunkReviewModel's recount and delta paths, so BatchReviewProcessor, split/merge and the repair CLI are all covered by construction. resetScore() and alterChunkReviewStruct() lock explicitly since they bypass ChunkReviewModel.

Your point 3 dissolves with the lock gone, but it's slightly worse than described and I left a note on it: the identifier embeds a per-instance uuid4 and ChunkReviewJobLock built a fresh RedisHandler per call, so a TTL-less stranded key was permanent, not just long-lived. tryLock() is unused again — left in place but docblocked with both defects.

The repair CLI also had no transaction at all, so I added one; that fixes the ProxySQL replica-read hazard you flagged.

Coverage

You were right that nothing asserted the actual exclusion property. Added lockByJobId_holds_the_rows_until_commit: a second connection attempting FOR UPDATE NOWAIT gets ER_LOCK_NOWAIT. It uses NOWAIT rather than sleeps, so it's deterministic and shouldn't flake on loaded CI — which also removes the wall-clock timing assertions you flagged, since ChunkReviewJobLockTest is deleted.

Also added the fractional-penalty real-SQL test you asked for (two 2.75 entries, asserting the recount and the detector agree afterwards).

Not done yet

Deliberately left your non-blocking items for a follow-up rather than growing this PR further — the unbounded findPenaltyPointsMismatches() (no LIMIT, uncapped email body), busting caches after commit rather than inside the transaction, and making the clamp observable when it fires. Happy to do them here instead if you'd rather they not ship separately. I can't EXPLAIN the detector query against production-sized data from my end, so that one needs someone who can.

One note on the diff: the truncation fix and the locking change both touch ChunkReviewDao and the same real-SQL test file, so they're in a single commit rather than split — sorry, it makes the diff a bit denser to read than I'd like.

Both repos have to deploy together, as you noted.

@mauretto78
mauretto78 requested a review from Ostico August 6, 2026 15:05
@mauretto78

Copy link
Copy Markdown
Contributor Author

@Ostico when you have a moment — re-requested your review above. No rush.

The one bit I would especially value your eyes on is the lockByJobId granularity: locking by id_job and leaning on the REPEATABLE READ gap lock to cover the split/merge delete→recreate window is the part I would most like a second opinion on, since it is doing more work than a plain row lock.

Also flagging again in case it is useful elsewhere: clamping the VALUES list as suggested in point 5 silently kills every decrement, because the ON DUPLICATE KEY UPDATE clause reads it back through VALUES(penalty_points).

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

🧪 Test-Guard Report

❌ FAIL — Some changed source files lack adequate test coverage.

Coverage Analysis: ❌ FAIL

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

📋 8 files: 3 ❌ fail, 5 ✅ pass
File Verdict Reason
lib/Model/LQA/ChunkReviewDao.php ✅ pass 100% diff coverage ≥ 80% threshold
lib/Model/QualityReport/QualityReportModel.php ❌ fail 25% diff coverage < 80% threshold
lib/Plugins/Features/AbstractRevisionFeature.php ❌ fail 50% 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/ReviewedWordCountModel.php ✅ pass 100% diff coverage ≥ 80% threshold
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php ✅ pass 100% diff coverage ≥ 80% threshold
lib/Utils/Redis/RedisHandler.php ✅ pass no executable lines changed (trivial: whitespace/comments)

Test File Matching: ❌ FAIL

File matching: 3 pass, 4 warning, 1 fail

📋 8 files: 1 ❌ fail, 4 ⚠️ 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 ✅ pass Test file modified in PR: tests/unit/Core/Features/AbstractRevisionFeatureTest.php
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/ReviewedWordCountModel.php ⚠️ warning Test file exists (tests/unit/Core/Features/ReviewExtended/ReviewedWordCountModelTest.php) but was not modified in this PR
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php ✅ pass Test file modified in PR: tests/unit/Core/Plugins/Features/ReviewExtended/TranslationIssueModelTest.php
lib/Utils/Redis/RedisHandler.php ⚠️ warning Test file exists (tests/unit/Core/Utils/Redis/RedisHandlerTest.php) but was not modified in this PR

Per-File Evaluation: ❌ FAIL

Evaluated 8 files: 2 via AI (1 batch), 6 via shortcuts.

📋 8 files: 1 ❌ fail, 2 ⚠️ warning, 4 ✅ pass, 1 ⏭️ skip
File Verdict Reason
lib/Model/LQA/ChunkReviewDao.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Plugins/Features/AbstractRevisionFeature.php ❌ fail shortcut → coverage 50% < 80%, relevant tests exist but insufficient
lib/Plugins/Features/ReviewExtended/ChunkReviewModel.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Plugins/Features/ReviewExtended/ReviewedWordCountModel.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Plugins/Features/ReviewExtended/TranslationIssueModel.php ✅ pass shortcut → coverage 100% ≥ 80%
lib/Utils/Redis/RedisHandler.php ⏭️ skip shortcut → trivial change (whitespace/comments only)
lib/Model/QualityReport/QualityReportModel.php ⚠️ warning Missing verification of database locking and cache invalidation logic.
lib/Plugins/Features/ReviewExtended/Email/PenaltyPointsDriftAlertEmail.php ⚠️ warning New email class implementation lacks any tests for content generation or sending behavior.

Result: ❌ FAIL


Why this FAIL?

  • Coverage: QualityReportModel.php, AbstractRevisionFeature.php, and PenaltyPointsDriftAlertEmail.php fall below the 80% threshold → Action needed to increase test coverage.
  • Test File Matching: PenaltyPointsDriftAlertEmail.php lacks a corresponding test file, while several other files have existing tests that were not updated despite code changes → Action needed to create/update test files.
  • AI Analysis: QualityReportModel.php lacks verification for database locking and cache invalidation; PenaltyPointsDriftAlertEmail.php lacks tests for email content generation → Action needed to implement these specific test cases.

To resolve: Add unit tests for PenaltyPointsDriftAlertEmail.php and extend existing test suites for QualityReportModel.php and AbstractRevisionFeature.php to cover the identified logic gaps.

@gitguardian

gitguardian Bot commented Aug 6, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Generic Password ba2457c tests/unit/Core/DAO/TestChunkReviewDAO/ChunkReviewDaoRealSqlTest.php View secret
- - Generic Password ba2457c tests/unit/Core/DAO/TestChunkReviewDAO/ChunkReviewDaoRealSqlTest.php View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@mauretto78

Copy link
Copy Markdown
Contributor Author

@Ostico one question on Test-Guard before I write more tests — it's your gate, so I'd rather follow your preference than guess.

The gate is the only red check (ci-cd / Run tests passes). It reports Changed lines: 79.0% covered (threshold: 80%) — one point short — with three per-file failures:

File Verdict Cause
QualityReportModel.php 25% resetScore's new lockByJobId is covered; the other three changed lines are in updateChunkReview, which TestableQualityReportModel overrides, so the real body (including the new destroyCachesFor) never runs.
AbstractRevisionFeature.php 50% The lockByJobId lines in postJobSplitted/postJobMerged are covered, but alterChunkReviewStruct has no behavioural test at all.
Email/PenaltyPointsDriftAlertEmail.php 0%, No matching test file found Never had a test — it came in with the PR's first commit, and you flagged it at 0% in your review.

The first two are clear and I'll just fix them: make one QualityReportModelTest case exercise the real updateChunkReview (asserting updateStruct and destroyCachesFor), and add the missing alterChunkReviewStruct tests — happy path plus the two ValidationError guards. That also answers your AI reviewer's note about "database locking and cache invalidation" verification.

The email is the one I want your call on. send() reads AppConfig::$ROOT . '/inc/Error_Mail_List.ini' directly, with no injection point, and it's about half the class's executable lines — so covering just the constructor and _getTemplateVariables lands around 50% and stays a per-file failure. Three options:

  1. Redirect AppConfig::$ROOT in the test only. Follows the existing HeartBeatTest precedent (save $ROOT, point at a temp dir with a fixture inc/Error_Mail_List.ini, restore in tearDown). No production change, and it exercises the real parse_ini_file path. Downside: mutates a global static mid-test.
  2. Extract a protected seam, e.g. getAlertRecipients() wrapping the parse_ini_file call, overridden in the test. Cleaner, no global mutation, and BatchReviewProcessorAlertEmail — same ini pattern, also untested — could use it later. Downside: production code changed purely for testability, and it grows this PR further.
  3. Waive the file. You noted the gate can't see the internal_scripts tests, and CheckChunkReviewPenaltyPointsDrift is this class's only consumer. If you'd rather it be excluded than have a test written around a static, that's fine by me — it just needs to come from you.

I lean towards 2, since it also gives BatchReviewProcessorAlertEmail a way in and avoids the global, but 1 is the smaller diff and I'm happy either way.

Unrelated, for the record: the CattoolTeamNameScriptContextTest@built page and four CommentControllerTest::*broker_unavailable* failures I see locally are environmental (stale local lib/View/index.html; amq reachable in-container so nothing throws) and aren't red in CI.

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