Build1 distinct publisher3 min readUpdated
A dev.to writeup traces two weeks of low-looking referral counts to a @Modifying increment that never bumped @Version, letting one stale save() overwrite the counter.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
A writeup published on dev.to describes roughly two weeks of debugging that began with someone noticing a referrer's click count looked low, and ended at a pattern most Spring Data codebases file under performance: a `@Modifying @Query` that increments a denormalized `total_clicks` column at the SQL level, on an entity that also carries a `@Version` field [1][3]. According to the author, that combination is a lost-update bug with the safety mechanism switched off, and because a counter only ever moves up, the corruption is always an understatement rather than an obvious error [1][1].
The reason for the bulk UPDATE is the ordinary one. A load-modify-save round trip per click is slow under fan-out, so the increment is pushed into a single statement that is atomic at the database level [2].
Then the timeline the author lays out. Hibernate has the row in memory with `totalClicks=42` and `version=7`. Two clicks arrive and are applied by the SQL increment. An unrelated service sets `payoutEmail` on the loaded entity and calls `save()`, and Hibernate writes back every column it tracks, the counter included [4][7]. The optimistic-lock check passes, because the version in memory still matches the version in the row: the increment query never touched `version` [4]. What goes to the database is `UPDATE referrer SET total_clicks = 42, payout_email = '...', version = 8 WHERE id = ? AND version = 7`, and the two clicks in between are gone [5]. The annotation on the increment already specified `clearAutomatically = true`, which did not prevent it [16][2].
The failure scales with traffic. The author reports that quiet weeks look correct and viral campaigns are wildly under-counted, because the hotter the counter, the more increments fall inside somebody's read-modify-write window [6].
The first fix is to stop Hibernate writing a column it has no business writing: `updatable = false` on `total_clicks` and `total_conversions`, so the only path that writes them is the explicit SQL update [9]. The team now applies that to any column mutated exclusively by a `@Modifying @Query`, including denormalized stats and queue depths [10].
That plugs the data loss and opens a different hole, which is the part worth internalising. With `updatable = false`, a `save()` no longer notices that the row changed underneath it, so a thread that loaded a stale `payoutEmail` before the counter bump will still commit successfully, when for audit purposes it should have taken an `OptimisticLockException` and retried [11][12]. The counter increment is a real mutation of the row; hiding it from the version column trades one class of silent overwrite for another.
The second fix is one clause: `r.version = COALESCE(r.version, 0) + 1` in the SET list of every `@Modifying @Query` UPDATE against an entity that has a `@Version` field [13]. The author says the audit touched around fifteen queries across `ReferrerRepository`, `TeamRepository`, `UrlVariantRepository` and others [14].
Worth watching in your own codebase: the enforcement, not the fix. The team added a guard test that reflectively walks repository methods, finds every `@Modifying @Query` UPDATE and checks the JPQL for a version assignment in the SET clause [15]. Without something like it, the invariant lasts exactly until the next repository method someone adds by copying an old one. And if you are auditing today, the tell is not an exception in a log; it is a counter that is plausible, monotonic and low.
Follow any of these and your For You feed starts watching them — no settings page required.
Ranked by verification strength, evidence, and original report placement.
An author writing on dev.to reports that a counter column, a @Modifying @Query that increments it, and a @Version field on the same entity together constitute a silent data-loss bug, and that his team spent about two weeks noticing that a referrer's click count looked low before diagnosing it.
In the failure timeline, Hibernate holds the entity in memory with totalClicks=42 and version=7; an unrelated service loads the entity, sets payoutEmail and calls save(), and the @Version check passes because the @Modifying increment query never bumped the version.
Hibernate issues UPDATE referrer SET total_clicks = 42, payout_email = '...', version = 8 WHERE id = ? AND version = 7, and two clicks are lost silently; the version mechanism did not prevent the lost update because the writes that bumped the counter never bumped the version.
The bug compounds under load: the hotter the counter, the more clicks get clobbered, so the counter looks fine in slow weeks and is wildly under-counted during viral campaigns.
The team increments a denormalized total_clicks counter on the referrer row for each referral short-link click, using a @Modifying @Query at the SQL level because a load-modify-save round trip would be slow under fan-out; the query is described as atomic at the DB level.
The ReferrerEntity carries both a @Version private Long version field and a @Column(name = "total_clicks") totalClicks field defaulting to 0.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Single self-reported postmortem with concrete code, no external corroboration
The mechanism is laid out precisely enough to be checked: entity annotations, the JPQL increment, the resulting UPDATE statement with the passing version predicate, and the two fixes. That internal specificity is real evidentiary weight. But it is one author's account on one platform, with no independent reproduction, no framework-maintainer or documentation citation, no Hibernate/Spring Data versions, and no measurement of how many clicks were actually lost. The sweeping opening claim that anyone with this annotation combination 'almost certainly' has the bug goes beyond what a single incident can establish.
One team's self-reported remediation; no third-party uptake
The only adoption visible is the reporting team's own: updatable = false applied as a blanket internal rule, version bumps added to around fifteen queries across several repositories, and a reflection guard test committed. That is concrete but confined to a single unnamed codebase, disclosed by the party that wrote it. No other team, project, framework default or tooling change is reported to have taken up the pattern.
Headline and universality overshoot a well-specified but narrow finding
The described defect loses increments and yields an undercount; it does not make a counter 'keep resetting to zero' as the title states, and the article itself never shows a reset. The lede's 'you almost certainly have a silent data-loss bug' also universalizes from one incident without accounting for codebases where nothing else calls save() on the entity or where the counter is not part of the managed entity. The underlying technical content is sound and modestly presented, so the gap is framing-level rather than substantive.
Mild self-promotion, no vendor or commercial stake
The post opens by noting it was originally published on the author's own blog and closes with an engagement prompt, so there is an audience-building incentive to frame a routine ORM pitfall as a widespread silent data-loss bug. Beyond that there is no product, sponsor, employer disclosure or competing framework being sold, and the recommended fixes are free annotation and test changes with no commercial beneficiary.
Internally coherent single-source account
Confidence is capped by the one-source cluster and the absence of any independent verification, but lifted by how checkable the account is: the annotations, JPQL, generated UPDATE and fixes are all shown, they are mutually consistent, and the failure mode follows logically from stated JPA semantics. Quantities such as 'about two weeks' and 'around fifteen queries' are self-reported approximations and cannot be validated.
build
The proxy in your call path decides whether @Transactional does anything at all1 distinct publisher
build
The stopping problem: an LLM rewrite loop that converged on code javac rejected1 distinct publisher
build
In Spring, the hook you need is decided by the clock, not by the name1 distinct publisher
build
One non-ASCII letter broke four toolchains: path checks belong in CI, not folklore1 distinct publisher
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 16, 2026