If I want to truncate an Instant to only include u...
# kotlinx-datetime
d
If I want to truncate an Instant to only include up till the hour's start (w/o minutes/seconds/etc...) what's the simplest/most ideomatic way to do this in KotlinX Datetime?
d
None at the moment, but an operation of this form was often requested (https://github.com/Kotlin/kotlinx-datetime/issues/325). Would you be willing to discuss your use case? This could help us a lot, and maybe we can suggest some reasonable implementation that doesn't require this operation.
d
Basically I need to track user actions in "per hour buckets", so I need to compare now() with my current bucket to see whether I need a new bucket or record the event in the old one.
d
Each bucket starts when it's 0 minutes 0 seconds on the clock in the chosen time zone, right?
d
Yeah, I don't really need the timezone in my case though... and also 0 millis etc...
I attempted this:
Copy code
@JvmInline
value class TruncatedInstant private constructor(val value: Instant) {
    operator fun compareTo(other: TruncatedInstant): Int =
        value.compareTo(other.value)

    companion object {
        val EMPTY = TruncatedInstant(Instant.DISTANT_PAST)

        operator fun invoke(instant: Instant): TruncatedInstant {
            val secondsSinceHourStart = instant.epochSeconds % 3600
            return TruncatedInstant(instant - secondsSinceHourStart.seconds)
        }
    }
}
but it seems like millis and nanos mess it up...
d
So is it okay if in the timezone your user is in, a new bucket starts at, say, 20:30 or 16:15, because they have some non-whole-hour offset from the UTC?
d
Well it's an Android app, and so the device is usually in the same time zone...
I don't send this data to a server, I just need to track it for allowing certain actions per hour
k
This is perhaps naive, but is there any reason you couldn't do:
Copy code
fun Instant.getBucketHour(): Int {
  return toLocalDateTime(UTC).hour
}
d
What I mean is, let's look at an
Instant
with
epochSeconds = 1737374400
(it was several minutes ago). Your timezone-oblivious logic would mark it as the start of a new bucket. But in Mumbai, this moment corresponds to 17:30, which is not a beginning of a new hour. Is that okay?
d
I guess as long as the user isn't travelling to Mumbai that same day/hour... is there any better alternative that would cover even that case? @kevin.cianfarini I still need to know that hour's day, it's per unique hour in a year/month/day...
👍 1
I guess I could always save the string form of Instant and then parse it if I wanted to cover funny cases...
Currently I'm just storing epochSeconds
d
It's not just about travel. In Mumbai, the buckets for people living there: 17:30 to 18:30, 18:30 to 19:30, 19:30 to 20:30, and so on.
d
I just really need to ensure that a user can't do more than a certain amount of actions during that hour range... it doesn't really matter the actual time it starts by
I need to increment the number of actions done in the current bucket for that
d
If the bucket boundaries are not important, are you sure buckets are better for your use case than a sliding window? If I do some action a lot at 13:55, and at 14:00, a new bucket starts, I'll be able to do two buckets' worth of actions in just 10 minutes. Is it not better to ensure that actions are forbidden if too many were already performed in the last 60 minutes?
☝️ 1
d
In some other cases we have that are common, we need to generate a hash with a truncated timestamp to authenticate requests, and allow a leeway between the client and server, then it might be more important... this is another potential use-case for this that's pretty common.
Is it not better to ensure that actions are forbidden if too many were already performed in the last 60 minutes?
I hear the point, what I said is currently implemented in a very messy way, and I'm trying to refactor w/o changing current behaviour, but I could talk to the project manager if he wants to change... for now, I'd rather stick to what's currently implemented. But it would be nice to see the other way you're proposing. Do you have any suggestions on how to implement both ways with the current library?
k
Are you storing these actions in a database? If so, the easy way would be to create a window where "now" is the end boundary and an hour ago is the start boundary, and then query for events that fall within that range of time.
d
Here's a (non-thread-safe) solution for sliding window:
Copy code
import kotlinx.datetime.*
import kotlin.time.Duration

class SlidingWindow(private val maxEventsPerDuration: Int, private val windowLength: Duration) {
    private val events = ArrayDeque<Instant>()
    
    /** Registers a new event, returning `false` if [maxEventsPerDuration] events already happened in the last [windowLength]. */
    fun tryRegisterNewEvent(now: Instant): Boolean {
        while (events.firstOrNull()?.let { now - it > windowLength } == true) {
            events.removeFirst()
        }
        if (events.size >= maxEventsPerDuration) return false
        events.addLast(now)
        return true
    }
}
and here's how you could implement a bucket that's refreshed hourly:
Copy code
data class LocalDateHour(val date: LocalDate, val hour: Int)

fun Instant.localDateHourInUtc() = toLocalDateTime(TimeZone.UTC).let {
    LocalDateHour(it.date, it.hour)
}
d
Interesting, thanks for both! But how would I save LocalDateHour? I'd need to save the LocalDate and hour combo... @kevin.cianfarini It's not being saved to a db, just regular sharedprefs... I currently need only the latest bucket's count saved... but I guess for any SlidingWindow, I'd need to save all those events... which is a bit of a disadvantage in this case...
c
Dave, is this server-side or client-side? Rate limiting usually needs to be done server-side to protect resources. If doing this client-side, it could be bypassed easily.
d
It's client side, it's not rate-limiting server access, but rather actions in an app... it's not mission critical that the limits should be so unbypassable, but rather just make it hard to bypass.
Also, they need to be easily comparable.
That's why I started with an Instant
d
But how would I save LocalDateHour?
Sorry, what do you mean? Where do you want to save it?
Also, they need to be easily comparable.
LocalDateHour
instances can be compared using
==
, because it's a
data class
.
d
In the app's shared preferences in case the user exits and enters the app in the same hour. And == would work if I'd convert the clock.now() to a LocalDateHour to check if the saved value is in the current hour, which I guess I can't avoid...
When I had Instant, I just saved it's epochSeconds as a Long
It could be like this I guess:
Copy code
data class LocalDateHour(val date: LocalDate, val hour: Int) {
    fun toEpochSeconds() = date.atTime(hour, 0).toInstant(TimeZone.UTC).epochSeconds
}
But I'd be converting back and forth a bunch of times...
d
If you want to store an
Instant
, your
TruncatedInstant
solution should work with this implementation:
Copy code
operator fun invoke(instant: Instant): TruncatedInstant {
            val secondsSinceHourStart = instant.epochSeconds.mod(3600)
            return TruncatedInstant(Instant.fromEpochSeconds(instant.epochSeconds - secondsSinceHourStart))
        }
This way, the sub-second portion doesn't get stored.
👍🏼 1
d
Thanks, that works great!
And thanks for the in-depth discussion, I really got a better understanding of the options available and pros and cons of each!
🙂 1