This article was originally published on Jo4 Blog.
If you're billing monthly via Stripe or RevenueCat, it's tempting to reset usage quotas inside your webhook handler. The customer pays → invoice fires → you zero out their urlCount. Clean. One source of truth.
It works for some customers. Two customer types it doesn't work for, and they're both yours:
- Yearly subscribers. Their payment event fires once a year. Without a separate reset, they get the monthly cap (e.g., 500 URLs/month on Pro) and then exhaust it forever, because nothing zeroes the counter for the next 11 months.
-
FREE users. They never trigger a payment event. Their
urlCountonly moves on URL create/delete, so it grows linearly and never resets.
We needed a quota reset that's decoupled from billing cadence. Here's the Spring scheduler + Postgres advisory lock job we built.
The Shape of the Problem
Two things must be true:
- The job runs once per calendar month, regardless of how many app instances are running.
- Two instances waking up at the same moment must not both run it. (We're not idempotent —
urlCount = 0is, but the audit log isn't, and we want exactly one log line per reset.)
The naive solution is a global database flag, but you race on that too. The clean solution in Postgres is pg_try_advisory_xact_lock — an instance-level lock keyed on an integer that auto-releases at transaction end.
The Scheduler
@Slf4j
@Component
@EnableScheduling
@RequiredArgsConstructor
public class MonthlyQuotaResetScheduler {
// Per-job lock ID. Distinct from other schedulers using advisory locks.
static final long LOCK_ID = 1234567891L;
private final UserRepository userRepository;
private final TeamRepository teamRepository;
private final EntityManager entityManager;
private final TransactionTemplate transactionTemplate;
/**
* Reset quotas at 00:05 UTC on the 1st of each month.
* Staggered 5 minutes off midnight to dodge the top-of-hour load spike.
*/
@Scheduled(cron = "0 5 0 1 * *", zone = "UTC")
public void resetMonthlyQuotas() {
transactionTemplate.execute(status -> {
Boolean acquired = (Boolean) entityManager
.createNativeQuery("SELECT pg_try_advisory_xact_lock(:lockId)")
.setParameter("lockId", LOCK_ID)
.getSingleResult();
if (!Boolean.TRUE.equals(acquired)) {
log.debug("Monthly reset skipped — another instance has the lock");
return null;
}
log.info("Acquired lock, running monthly quota reset...");
long now = System.currentTimeMillis();
int users = userRepository.resetAllUrlCounts(now);
int teams = teamRepository.resetAllUrlCounts(now);
log.info("Reset complete: users={}, teams={}", users, teams);
return null;
});
}
}
A few choices worth calling out:
Cron 0 5 0 1 * *. Five minutes past midnight UTC on the first of the month. The 5-minute offset matters more than it looks — every other top-of-hour cron job in your infrastructure is also waking up at 0 0 0 1 * *. Stagger.
pg_try_advisory_xact_lock (not pg_advisory_xact_lock). The try_ variant returns false instead of blocking. If another instance has the lock, we return immediately and log a debug line. No piled-up workers waiting to do nothing.
Transaction-scoped lock (xact_lock, not lock). The lock auto-releases when the transaction commits or rolls back. We never have to remember to release it. If the JVM crashes mid-job, the connection drops, the transaction aborts, and the lock evaporates with it.
@EnableScheduling on the component. Spring needs this annotation somewhere in the application context. Putting it on the scheduler class itself makes the dependency local — drop the class, drop the annotation, no orphan config.
The Repository UPDATE
@Modifying
@Query("UPDATE UserEntity u SET u.urlCount = 0, u.modifiedTime = :now "
+ "WHERE u.deleted = false")
int resetAllUrlCounts(@Param("now") Long now);
Single SQL UPDATE across the whole table, inside the same transaction as the advisory lock. If you're tempted to fetch every user and save() each one — don't. That's O(n) round-trips and O(n) Hibernate dirty-checking. The bulk UPDATE is O(1) round-trips.
The Admin Override
Production rule of thumb: if the system can do it on a schedule, an admin will eventually need to do it for one customer right now. We added a single-user reset on the admin panel:
@Transactional
public UserEntity resetUrlCount(Long userId) {
UserEntity user = userRepository.findById(userId)
.filter(u -> !Boolean.TRUE.equals(u.getDeleted()))
.orElseThrow(() -> new AppException(ErrorCode.USER_NOT_FOUND));
int previous = user.getUrlCount() != null ? user.getUrlCount() : 0;
userRepository.resetUrlCountForUser(userId, System.currentTimeMillis());
log.info("Admin reset urlCount for user {}: {} -> 0", userId, previous);
return userRepository.findById(userId).orElse(user);
}
Two details:
-
Filter on
deleted = falsebefore resetting. Don't unblock soft-deleted accounts. - Log the previous value. "Reset urlCount" with no context tells you nothing in an audit. "Reset urlCount for user 42: 487 → 0" tells you exactly what happened.
Bonus: The Sticky Override
Some customers need a permanent quota bump that survives plan changes, billing webhooks, and the monthly reset. We added a nullable urlLimitOverride column on the user entity:
public int getEffectiveUrlLimit() {
// Admin override always wins.
if (urlLimitOverride != null) {
return urlLimitOverride;
}
// Defend against missed billing webhooks.
if (!hasActiveSubscription() && subscriptionTier != FREE) {
return FREE_TIER_URL_LIMIT;
}
return urlLimit;
}
Three rules of precedence:
- Admin override wins. Settable only via an admin-only endpoint, never mapped from any user-facing DTO.
- Lapsed subscription on a paid tier falls back to FREE limits. This is the safety net for missed webhooks.
- Otherwise, the stored
urlLimit(kept in sync with the plan config by the billing webhooks).
The override is sticky on purpose — billing webhooks don't touch it, the monthly reset doesn't touch it, the daily plan-config sync doesn't touch it. An admin sets it, and an admin clears it.
Pitfalls We Hit (So You Don't)
- Don't put the reset inside billing webhooks. Yearly subscribers and FREE users will resent you for it.
-
Don't use an unbounded
pg_advisory_lockwithout a try variant. Two instances will both wake up; one blocks for the other; the second one runs the job again immediately after the first commits. - Don't forget the audit log row. When a customer asks "why did my quota suddenly drop on the 1st?", the answer should be one query away.
-
Don't bundle the override into the user-facing profile DTO. Defense in depth — the only path to the column is the admin endpoint with
requireAdmin()upstream. -
Don't skip the idempotency test. Run the job twice in the same minute. The second run should be a no-op (the lock holder rejected it) or set
urlCountfrom 0 to 0 (still a no-op). Both are fine. Anything else is a bug.
How do you decouple usage resets from billing cadence? What's your scheduler setup look like? Drop it in the comments.
Building jo4.io — a URL shortener where the quota math is the easy part of the platform.
Top comments (0)