Repository navigation
feat: Locksmith 4.0.0, ground-up rewrite - #77
Open
garvit-joshi wants to merge 1 commit into
Open
garvit-joshi wants to merge 1 commit into
garvit-joshi wants to merge 1 commit into
Conversation
Distributed locks and semaphores for Spring Boot 4.1 on Redisson 4, rebuilt from scratch. Rate limiting, the AspectJ aspects, the templates with callbacks and the skip handlers are gone. The public API, the properties, the metrics and the Redis key layout change; CHANGELOG.md lists every change and the 3.x migration table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Locksmith 4.0.0: Redis-based distributed locks and semaphores for Spring Boot 4.1, built on Redisson 4. Use them through the
@DistributedLockand@DistributedSemaphoreannotations, or from code through theLockOperationsandSemaphoreOperationsbeans.The README is now a short front page. The full docs are in the wiki, rewritten for 4.0.0.
Changelog
A ground-up rewrite. The API, the properties, the metrics and the Redis keys change, and nothing from 3.x is kept for compatibility.
Added
@DistributedLockand@DistributedSemaphoreis checked when its bean is created, and a misconfiguration fails the context refresh with aLocksmithConfigurationExceptionthat names the class, the method and the value. 3.x checked keys, durations and permit counts on each call.suspendfunction, or aFutureorCompletionStagewithout@Async. 3.x released the lock or permit when such a method returned, while its work could still be running. An annotated@Asyncmethod must returnvoid, KotlinUnit,FutureorCompletableFuture.@DistributedSemaphoreannotations use one key text with different permit counts, or two@DistributedLockannotations use one key text, one withREENTRANTand one withREADorWRITE.LocksmithException, the unchecked base of every Locksmith exception, andLocksmithConfigurationExceptionfor a misconfiguration.SemaphoreOperations.availablePermits(key), never below zero, andkey()onLockHandleandPermitHandle, the full Redis key.RedissonClientbean, and one whenlocksmith.enabledisfalse. 3.x logged nothing in either case.Changed
Ordered.LOWEST_PRECEDENCE - 1: after Spring Security's method checks, and around a@Transactionalat its default order, so the transaction commits before the lock is released. 3.x ran atOrdered.HIGHEST_PRECEDENCE, before the security checks. With both annotations on one method, the permit is taken before the lock and released after it.leaseTimeis renewed while it is held (Redisson's watchdog). 3.x gave it a fixed 10-minute lease fromlocksmith.lock.lease-time.waitTimealone decides whether an acquire waits; blank or zero means try once. 3.x annotations waited only withmode = WAIT_AND_SKIP, 60 seconds by default, and otherwise ignoredwaitTime.onFailurereplacesskipHandler:THROW, the default, throws the not-acquired exception,SKIPreturns the default value of the return type, andHANDLERreturns what the handler bean returns.handlernames aLockFailureHandlerorSemaphoreFailureHandlertype that must have exactly one bean; Locksmith never creates it by reflection.LockOperationsandSemaphoreOperationsbeans replace the templates, and the annotations run through the same code. Only the handle style is kept: there is no callback API and no release by key.#{...}islands, such as"user:#{#userId}". 3.x evaluated a key only when the whole key was one#{...}, so"user:#{#userId}"stayed literal. A key, or any island of it, that resolves to null or blank throwsLocksmithConfigurationException.READandWRITElocks have their own Redis key, apart fromREENTRANTlocks of the same key, and the two kinds do not exclude each other. In 3.x they shared one key, where a heldREENTRANTlock did not blockREADorWRITE.locksmith.enabled,locksmith.key-prefix(defaultlocksmith:) andlocksmith.semaphore.lease-time(default 5m) replace the per-primitive properties. Redis keys move tolocksmith:lock:<key>,locksmith:rwlock:<key>andlocksmith:semaphore:<key>.locksmith.acquireandlocksmith.held, recorded whenever aMeterRegistrybean exists. They replace 3.x's per-primitive counters, timers and gauge, and itsmetrics-enabledswitches.IllegalStateExceptionbefore anything is sent: on an I/O or timer thread always, and with a wait time on any other Redisson thread, such as a topic listener.isLockedandavailablePermitsrefuse the I/O and timer threads. 3.x refused only the I/O threads, through Redisson.Removed
@RateLimit,LocksmithRateLimitTemplateand everything around them.DistributedLockAspect,DistributedSemaphoreAspectandRateLimitAspect.LocksmithLockTemplateandLocksmithSemaphoreTemplate, with their callback API,unlock(key)andreleasePermit(key, permitId).mode,autoRenewandonLeaseExpiredattributes, withAcquisitionMode,LeaseExpirationBehaviorand the builder'sautoRenew().LeaseExpiredException,SemaphoreLeaseExpiredExceptionandSemaphoreConfigurationException.debugandmetrics-enabledamong them, andLocksmithMetricsAutoConfiguration.DefaultValueResolver,DurationResolverandSpELKeyResolver, and the metrics classesLockMetricsandSemaphoreMetrics.Fixed
RedissonClientbean comes fromredisson-spring-boot-starter. 3.x's auto-configuration was evaluated before the starter's, found no client and registered nothing, so annotated methods ran without a lock or permit.MeterRegistrythat Spring Boot auto-configures. 3.x's metrics auto-configuration was evaluated before Spring Boot's, found no registry and recorded nothing, even withmetrics-enabled, unless the application declared its ownMeterRegistrybean.RedisExceptioninstead of reaching the skip handler, while the command could still take the lock or permit. It then stayed held until its lease ran out, and a lock withautoRenewkept being renewed.LockHandleclosed on another thread than the one that acquired the lock releases it. In 3.x the release ran as the closing thread and left the lock held, usually with a WARN that itwas already released (possibly expired).close()of aLockHandleunlocked again, which could end a hold the same thread had taken since.close()no longer waits forever when theRedissonClientshuts down while Redis is slow to answer a release; it stops waiting within about a second, and the lock or permit expires on its own. In 3.x some of thoseclose()calls never returned.order:{42, throwsLocksmithConfigurationExceptionbefore anything is sent. In 3.x such aREENTRANTorWRITElock was taken but its release failed, so it stayed until it expired, and every call of such aREADlock or semaphore failed.locksmith.enabledmust betrueorfalse, in any case; any other value fails the startup with aLocksmithConfigurationExceptionthat names it. In 3.x,yesforlocksmith.lock.enabledorlocksmith.semaphore.enabled, set in a properties file or the environment, switched that annotation off without a word.OnFailure.SKIPreturns an emptyOptionalInt,OptionalLongorOptionalDouble; 3.x'sReturnDefaultHandlers returnednull.Migration from 3.x
One row per dropped or changed item. Class names without a package are in
in.riido.locksmithor the package named in the 4.0 column.org.aspectj:aspectjweaverrequired on the classpathaspect.DistributedLockAspect,DistributedSemaphoreAspect,RateLimitAspectOrdered.HIGHEST_PRECEDENCE, relative order not fixedOrdered.LOWEST_PRECEDENCE - 1: after Spring Security's method checks, before@Transactionalat its default order; permit first, then lockLocksmithMetricsAutoConfigurationautoconfigure.LocksmithAutoConfigurationspring.autoconfigure.excludeand fromexclude = ...on@SpringBootApplication; the class no longer exists.@RateLimit(with Redisson'sRateTypefortype)template.LocksmithRateLimitTemplate,template.callback.RateLimitCallback,handler.RateLimitSkipHandler,handler.ratelimit.RateLimitThrowExceptionHandler,handler.ratelimit.RateLimitReturnDefaultHandler,models.RateLimitContext,exception.RateLimitExceededException,exception.RateLimitConfigurationException,metrics.RateLimitMetrics,support.RateLimitConfiglocksmith.rate-limit.*properties (enabled,wait-time,key-prefix,debug,metrics-enabled)@DistributedLock(mode = ...),@DistributedSemaphore(mode = ...),AcquisitionMode(SKIP_IMMEDIATELY,WAIT_AND_SKIP)waitTimealone decidesmode. Where it wasWAIT_AND_SKIP, setwaitTime, which 3.x defaulted to 60s. Where it was not, remove anywaitTime: 3.x ignored it there, and 4.0 waits that long.locksmith.lock.wait-time,locksmith.semaphore.wait-time(default 60s, used byWAIT_AND_SKIP)waitTimeon each annotation or builder that should wait.leaseTimeblank, or not set on the builder: fixed lease fromlocksmith.lock.lease-time(10m), not renewedleaseTime.locksmith.lock.lease-timeleaseTimeper annotation where a fixed lease is wanted.@DistributedLock(autoRenew = true), builderautoRenew()leaseTimeblank or unset.@DistributedLock(onLeaseExpired = ...),@DistributedSemaphore(onLeaseExpired = ...),LeaseExpirationBehaviorexception.LeaseExpiredException,exception.SemaphoreLeaseExpiredExceptionleaseTimeaccepted and passed to Redisson, which then renews a lock instead of expiring itIllegalArgumentExceptionin the builders1ms, or leave a lock lease blank for renewal.skipHandler = ...attributeonFailureplushandleronFailure = THROW,SKIPorHANDLER(withhandler = ...).handler.lock.LockThrowExceptionHandler,handler.semaphore.SemaphoreThrowExceptionHandler(built-in, default)OnFailure.THROW, the defaultskipHandlerattribute.handler.lock.LockReturnDefaultHandler,handler.semaphore.SemaphoreReturnDefaultHandlerOnFailure.SKIPonFailure = OnFailure.SKIP. It returns the same defaults, except an emptyOptionalInt,OptionalLongorOptionalDoublewhere 3.x returnednull.handler.LockSkipHandler.handle(LockContext),handler.SemaphoreSkipHandler.handle(SemaphoreContext)lock.LockFailureHandler.onFailure(LockFailureContext),semaphore.SemaphoreFailureHandler.onFailure(SemaphoreFailureContext)onFailure = OnFailure.HANDLER, handler = YourHandler.class.models.LockContext(lockKey, methodName, method, args, returnType)lock.LockFailureContext(key, method, args, waitTime)key(); usemethod().getName()andmethod().getReturnType()for the dropped fields.models.SemaphoreContext(semaphoreKey, methodName, method, args, returnType, permitId)semaphore.SemaphoreFailureContext(key, permits, method, args, waitTime)handler.DefaultValueResolver(public)OnFailure.SKIPapplies the defaults.template.LocksmithLockTemplatelock.LockOperationsbeanLockOperations.template.LocksmithSemaphoreTemplatesemaphore.SemaphoreOperationsbeanSemaphoreOperations.withKey(String)key(String)LockOperationBuilder.lockType(LockType)LockOperations.Builder.type(LockType)tryLock(),tryAcquire()on the buildersacquire()execute(callback)on the builders,template.callback.LockCallback,template.callback.SemaphoreCallbackacquired().template.handle.LockHandle,template.handle.PermitHandlelock.LockHandle,semaphore.PermitHandleLockHandle.isAcquired(),PermitHandle.isAcquired()acquired()key()is new on both.LockHandle.acquired(...),LockHandle.notAcquired(),PermitHandle.acquired(...),PermitHandle.notAcquired()(public factories)LocksmithLockTemplate.unlock(key),unlock(key, type)LockHandlethat acquired the lock.LocksmithLockTemplate.isLocked(key)LockOperations.isLocked(key, type)LockType.LocksmithSemaphoreTemplate.releasePermit(key, permitId)PermitHandle.@DistributedSemaphore(permits = 5):int, default 1permits = "5":String, required,${...}placeholders resolved"${reports.max-concurrent}".<key>:metabucket in Redissemaphore:*:metakeys. Keep one count per key.exception.SemaphoreConfigurationException, for a count below one or not set, and for one key used with two counts in one JVMLocksmithConfigurationException, at startup for annotations; two annotations with one key text and different counts fail the startup; throughSemaphoreOperationsthe last writer wins#{...};"user:#{#id}"was a literal#{...}islands, so"user:#{#id}"is evaluated#{.#{...}value turned into text withtoString()String, such as aLocalDate, fails each call with aSpelEvaluationExceptiontoString()in the key, such as#{#order.id}or#{#day.toString()}.-parametershintIllegalArgumentExceptionLocksmithConfigurationException, also when one#{...}island of a longer key resolves to null or blank#rootor a bean reference such as@myBeanin a#{...}key: each call failedexceptionpackagelock.LockNotAcquiredException,semaphore.SemaphoreNotAcquiredException,LocksmithConfigurationException,LocksmithExceptionRuntimeExceptionLocksmithException, which extendsRuntimeExceptionLocksmithExceptionto handle every Locksmith error.LockNotAcquiredException(lockKey, methodName),getLockKey(),getMethodName()(key, waitTime),key(),waitTime(); messageLock [<key>] not acquired within <waitTime>key(); the method name is no longer carried.SemaphoreNotAcquiredException(semaphoreKey, methodName),getSemaphoreKey(),getMethodName()(key, permits, waitTime),key(),permits(),waitTime(); messageSemaphore [<key>] permit not acquired within <waitTime> (permits <n>)key().locksmith.lock.enabled,locksmith.semaphore.enabledlocksmith.enabled, one switch for both;falselogs one WARN; any value buttrueorfalsefails the startuplocksmith.enabled.locksmith.lock.key-prefix(lock:),locksmith.semaphore.key-prefix(semaphore:)locksmith.key-prefix(locksmith:) plus a fixedlock:,rwlock:orsemaphore:partlocksmith.key-prefixif you need another prefix.lock:<key>andsemaphore:<key>locksmith:lock:<key>,locksmith:rwlock:<key>for read and write locks, andlocksmith:semaphore:<key>locksmith.semaphore.lease-time: zero or negative replaced by the default1ms.locksmith.lock.debug,locksmith.semaphore.debug; each acquire, release and skip of an annotated method logged at INFOlogging.level.in.riido.locksmith=DEBUGto see them.locksmith.lock.metrics-enabled,locksmith.semaphore.metrics-enabledMeterRegistrybean existsautoconfigure.LocksmithPropertieswith nestedLockProperties,SemaphoreProperties,RateLimitPropertiesanddefaults()LocksmithProperties(enabled, keyPrefix, semaphore)with nestedSemaphore(leaseTime)locksmith.lock.acquired,locksmith.semaphore.acquired(counters)locksmith.acquiretimer withoutcome=acquiredprimitive=lockorsemaphore.locksmith.lock.skipped,locksmith.semaphore.skippedwith tagreason=immediateortimeoutlocksmith.acquirewithoutcome=skipped;outcome=interruptedis new; noreasontaglocksmith.lock.acquisition.time,locksmith.semaphore.acquisition.timelocksmith.acquire, one record per acquire that returns, the time it tooklocksmith.lock.held.time,locksmith.semaphore.held.timelocksmith.heldwith tagprimitivelocksmith.lock.lease.expired,locksmith.semaphore.lease.expiredlocksmith.lock.autorenew.activeaspect,exception,handler(withlock,semaphore,ratelimit),models,template(withcallback,handle); the public classes ofsupportandmetricsin.riido.locksmith,lock,semaphore,autoconfigure;aop,supportandmetricsare internalDurationResolver,SpELKeyResolver,LockMetricsandSemaphoreMetrics.