How We Reset Monthly Quotas Without Race Conditions or Cron Drift
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 urlCount only 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 = 0 is, 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 = false before 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_lock without 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 urlCount from 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)
Comments
No comments yet. Start the discussion.