Coroutines, repeat & rate limiting
SporeCoroutines gives you a scope that's tied to your plugin's lifetime, so anything still running gets cancelled on disable instead of leaking into the next reload.
launch { /* starts on the main thread */ }
launchAsync { /* starts off the main thread */ }
Both are top-level functions (me.clearedSpore.sporeAPI.coroutine.launch), SporeCoroutines.launch { } still works too. Inside a coroutine, plain launch { } is kotlinx's own, which starts a child of the current coroutine - usually exactly what you want there.
Inside a coroutine you switch threads with withAsyncCtx and withRunCtx, which is the pattern for "load something slow, then touch the world with it":
launch {
val data = withAsyncCtx { repository.findBlocking(id) } // off the main thread
withRunCtx { player.sendMessage("Loaded $data") } // back on the main thread
}
delayTicks(20) is there too if you think in ticks rather than milliseconds.
The main dispatcher only actually schedules a task when it has to - if you're already on the server thread, withRunCtx runs your block inline.
Repeating and delayed work
repeat(5.seconds) { sendHeartbeat() }
repeat(every = 1.minutes, delay = 30.seconds, async = true) { pushConfig() }
delay(10.seconds) { player.sendMessage("10 seconds later") }
- They return a
Job: callcancel()to stop or call it off. Everything also stops when the plugin disables / the proxy shuts down. repeatwaits for a run to finish before the next one starts, so runs never pile up or overlap.- A run that throws is reported as an incident and the loop keeps going.
- They start on the main thread on Paper, pass
async = truefor blocking work. The block is a coroutine, so you can usewithAsyncCtx,delay, etc. inside it.
Durations are Kotlin's (import kotlin.time.Duration.Companion.seconds). Kotlin's own repeat(3) { } and the suspending delay(1.seconds) keep working, they only differ in arguments.
Rate limiting
private val chatLimit = RateLimiter<UUID>(limit = 5, per = 10.seconds)
if (!chatLimit.tryAcquire(player.uniqueId)) {
player.error("Slow down! Try again in ${chatLimit.retryAfter(player.uniqueId).inWholeSeconds}s")
return
}
tryAcquire(key)uses one go if there is one left,remaining(key)andretryAfter(key)tell you where a key stands.acquire(key)suspends until a go is free instead of failing - good for webhooks and APIs with rate limits.- Uses refill smoothly over time (a token bucket), and keys that sit idle are dropped, so it doesn't grow forever.
Caching suspend loads
When a value is expensive to load - a database page, a stats query, a permission lookup - and gets asked for a lot, SuspendCache keeps the result around for a while so you don't hit the database every time.
private val cache = SuspendCache<UUID, Stats>(Duration.ofMinutes(1), maximumSize = 5_000)
suspend fun stats(uuid: UUID): Stats = cache.get(uuid) { StatsRepository.loadBlocking(uuid) }.value
The loader runs off the main thread, so it's fine to call blocking code in it. A few things it handles for you:
- One load per key. If ten players open the same page at once, the loader runs once and they all get the same result.
- Invalidation wins.
invalidate(key)orinvalidateAll()throws away a load that was still running, so a stale result can't sneak back into the cache after you changed the data. - You know how old it is.
getreturns aCached<V>with thevalueandloadedAt, handy for an "updated 30s ago" line.
Entries expire after the ttl, and the cache never holds more than maximumSize entries.