Pages: Coroutines, repeat & rate limiting

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: call cancel() to stop or call it off. Everything also stops when the plugin disables / the proxy shuts down.
  • repeat waits 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 = true for blocking work. The block is a coroutine, so you can use withAsyncCtx, 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) and retryAfter(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) or invalidateAll() 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. get returns a Cached<V> with the value and loadedAt, handy for an "updated 30s ago" line.

Entries expire after the ttl, and the cache never holds more than maximumSize entries.