Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes and improvements for Snowflake #370

Merged
merged 14 commits into from
Aug 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions common/src/main/kotlin/entity/Interactions.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dev.kord.common.entity

import dev.kord.common.annotation.KordExperimental
import dev.kord.common.annotation.KordPreview
import dev.kord.common.entity.optional.Optional
import dev.kord.common.entity.optional.OptionalBoolean
import dev.kord.common.entity.optional.OptionalSnowflake
Expand Down Expand Up @@ -625,7 +624,7 @@ fun CommandArgument<*>.boolean(): Boolean {


fun CommandArgument<*>.snowflake(): Snowflake {
val id = string().toLongOrNull() ?: error("$value wasn't a Snowflake")
val id = string().toULongOrNull() ?: error("$value wasn't a Snowflake")
return Snowflake(id)
}

Expand Down
86 changes: 65 additions & 21 deletions common/src/main/kotlin/entity/Snowflake.kt
Original file line number Diff line number Diff line change
@@ -1,40 +1,65 @@
package dev.kord.common.entity

import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.datetime.Instant
import kotlin.time.Duration
import kotlin.time.TimeMark

/**
* A unique identifier for entities [used by discord](https://discord.com/developers/docs/reference#snowflakes).
*
* @constructor Creates a Snowflake from a given Long [value].
* Note: this class has a natural ordering that is inconsistent with [equals],
* since [compareTo] only compares the first 42 bits of the ULong [value] (comparing the timestamp),
* whereas [equals] uses all bits of the ULong [value].
* [compareTo] can return `0` even if [equals] returns `false`,
* but [equals] only returns `true` if [compareTo] returns `0`.
*
* @constructor Creates a Snowflake from a given ULong [value].
*/
@Serializable(with = Snowflake.Serializer::class)
class Snowflake(val value: Long) : Comparable<Snowflake> {
class Snowflake(val value: ULong) : Comparable<Snowflake> {

/**
* Creates a Snowflake from a given String [value], parsing it a [Long] value.
* Creates a Snowflake from a given String [value], parsing it as a [ULong] value.
*/
constructor(value: String) : this(value.toLong())
constructor(value: String) : this(value.toULong())

Comment on lines -21 to 33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest retaining a Long constructor for ease of use and migration, I imagine plenty of people have a Snowflake(long) somewhere defined as a constant and we're a far ways off from reaching the most significant bit.

Copy link
Member Author

@lukellmann lukellmann Aug 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will add this, but two decisions are needed:

  • Should it use invoke for companion object or a function as a pseudo constructor (normal constructor will lead to conflicting JVM overloads)?
  • Should it throw on negative values or just use a ULong with the same binary representation (meaning -1 will give a Snowflake with maximum timestamp)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be ok to throw an exception if I add @Deprecated to the pseudo constructor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it use invoke for companion object or a function as a pseudo constructor (normal constructor will lead to conflicting JVM overloads)?

a top-level factory function. We're trying to move away from companion invokes ever since the naming rules for factory function changed.

Should it throw on negative values or just use a ULong with the same binary representation (meaning -1 will give a Snowflake with maximum timestamp)?

The scenario shouldn't happen all that, so I'm not really concerned with breaking changes. However, negative values were valid (although undocumented) before this change. It might be best to just handle them as unsigned values as well.

I think it would be ok to throw an exception if I add @deprecated to the pseudo constructor.

I wouldn't want it to be marked Deprecated, the whole point of adding the faux constructor is convenience.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

/**
* Creates a Snowflake from a given [instant].
*
* If the given [instant] is too far in the past / future, this constructor will create
* an instance with a [timeStamp] equal to [Snowflake.min] / [Snowflake.max].
*/
constructor(instant: Instant) : this((instant.toEpochMilliseconds() shl 22) - discordEpochLong)
constructor(instant: Instant) : this(
instant.toEpochMilliseconds()
.coerceAtLeast(discordEpochLong) // time before is unknown to Snowflakes
.minus(discordEpochLong)
.coerceAtMost(maxMillisecondsSinceDiscordEpoch) // time after is unknown to Snowflakes
.toULong()
.shl(22)
)

/**
* A [String] representation of this Snowflake's [value].
*/
val asString get() = value.toString()

val timeStamp: Instant get() = Instant.fromEpochMilliseconds(discordEpochLong + (value shr 22))
/**
* The point in time this Snowflake represents.
*/
val timeStamp: Instant get() = Instant.fromEpochMilliseconds(discordEpochLong + (value shr 22).toLong())

val timeMark: TimeMark get() = SnowflakeMark(value shr 22)
/**
* A [TimeMark] for the point in time this Snowflake represents.
*/
val timeMark: TimeMark get() = SnowflakeTimeMark(timeStamp)

override fun compareTo(other: Snowflake): Int = value.shr(22).compareTo(other.value.shr(22))

Expand All @@ -47,36 +72,55 @@ class Snowflake(val value: Long) : Comparable<Snowflake> {
}

companion object {
private const val discordEpochLong = 1420070400000L
private const val discordEpochLong = 1420070400000L // 42 one bits
private const val maxMillisecondsSinceDiscordEpoch = 0b111111111111111111111111111111111111111111L

/**
* The point in time that marks the Discord Epoch (the first second of 2015).
*/
val discordEpochStart: Instant = Instant.fromEpochMilliseconds(discordEpochLong)

/**
* The last point in time a Snowflake can represent.
*/
val endOfTime: Instant = Instant.fromEpochMilliseconds(discordEpochLong + maxMillisecondsSinceDiscordEpoch)

/**
* The maximum value a Snowflake can hold.
* Useful when requesting paginated entities.
*/
val max: Snowflake = Snowflake(Long.MAX_VALUE)
val max: Snowflake = Snowflake(ULong.MAX_VALUE)

/**
* The minimum value a Snowflake can hold.
* Useful when requesting paginated entities.
*/
val min: Snowflake = Snowflake(0)

val min: Snowflake = Snowflake(ULong.MIN_VALUE)
}

internal class Serializer : KSerializer<Snowflake> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("Kord.Snowflake", PrimitiveKind.LONG)
@OptIn(ExperimentalSerializationApi::class)
internal object Serializer : KSerializer<Snowflake> {
override val descriptor: SerialDescriptor =
@OptIn(ExperimentalUnsignedTypes::class) ULong.serializer().descriptor

override fun deserialize(decoder: Decoder): Snowflake = Snowflake(decoder.decodeLong())
override fun deserialize(decoder: Decoder): Snowflake =
Snowflake(decoder.decodeInline(descriptor).decodeLong().toULong())

override fun serialize(encoder: Encoder, value: Snowflake) {
encoder.encodeLong(value.value)
encoder.encodeInline(descriptor).encodeLong(value.value.toLong())
}
}
}

private class SnowflakeMark(val epochMilliseconds: Long) : TimeMark() {
private class SnowflakeTimeMark(private val timeStamp: Instant) : TimeMark() {

override fun elapsedNow(): Duration = Instant.fromEpochMilliseconds(epochMilliseconds) - Clock.System.now()
override fun elapsedNow(): Duration = Clock.System.now() - timeStamp
}

/**
* Creates a [Snowflake] from a given Long [value].
*
* Note: a negative [value] will be interpreted as an unsigned integer with the same binary representation, e.g.
* passing `-1L` for [value] will return a [Snowflake] with a [value][Snowflake.value] of [ULong.MAX_VALUE].
*/
fun Snowflake(value: Long): Snowflake = Snowflake(value.toULong())
23 changes: 12 additions & 11 deletions common/src/main/kotlin/entity/optional/OptionalSnowflake.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package dev.kord.common.entity.optional

import dev.kord.common.entity.Snowflake
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
Expand Down Expand Up @@ -66,13 +66,13 @@ sealed class OptionalSnowflake {
* Represents a field that was assigned a non-null value in the serialized entity.
* Equality and hashcode is implemented through its [value].
*
* @param longValue the value this optional wraps.
* @param uLongValue the value this optional wraps.
*/
class Value(private val longValue: Long) : OptionalSnowflake() {
class Value(private val uLongValue: ULong) : OptionalSnowflake() {

constructor(value: Snowflake) : this(value.value)

override val value: Snowflake get() = Snowflake(longValue)
override val value: Snowflake get() = Snowflake(uLongValue)

/**
* Destructures this optional to its [value].
Expand All @@ -89,16 +89,17 @@ sealed class OptionalSnowflake {
override fun hashCode(): Int = value.hashCode()
}

@OptIn(ExperimentalSerializationApi::class)
internal object Serializer : KSerializer<OptionalSnowflake> {

override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor("Kord.OptionalSnowflake", PrimitiveKind.LONG)
get() = @OptIn(ExperimentalUnsignedTypes::class) ULong.serializer().descriptor

override fun deserialize(decoder: Decoder): OptionalSnowflake = Value(decoder.decodeLong())
override fun deserialize(decoder: Decoder): OptionalSnowflake =
Value(decoder.decodeInline(descriptor).decodeLong().toULong())

override fun serialize(encoder: Encoder, value: OptionalSnowflake) = when (value) {
Missing -> Unit//ignore value
is Value -> encoder.encodeLong(value.value.value)
Missing -> Unit // ignore value
is Value -> encoder.encodeInline(descriptor).encodeLong(value.value.value.toLong())
}

}
Expand All @@ -122,4 +123,4 @@ fun Snowflake?.optionalSnowflake(): OptionalSnowflake.Value? = this?.optionalSno
inline fun <T : Any> OptionalSnowflake.map(mapper: (Snowflake) -> T): Optional<T> = when (this) {
OptionalSnowflake.Missing -> Optional.Missing()
is OptionalSnowflake.Value -> Optional.Value(mapper(value))
}
}
57 changes: 57 additions & 0 deletions common/src/test/kotlin/entity/SnowflakeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package entity

import dev.kord.common.entity.Snowflake
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit.Companion.MILLISECOND
import kotlinx.datetime.Instant
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlin.test.*

class SnowflakeTest {

@Test
fun `Snowflake with value ULong MIN_VALUE has timeStamp equal to discordEpochStart`() {
val snowflake = Snowflake(ULong.MIN_VALUE)
assertEquals(Snowflake.discordEpochStart, snowflake.timeStamp)
}

@Test
fun `Snowflake with value ULong MAX_VALUE has timeStamp equal to endOfTime`() {
val snowflake = Snowflake(ULong.MAX_VALUE)
assertEquals(Snowflake.endOfTime, snowflake.timeStamp)
}

@Test
fun `Snowflake created from instant far in the past has timeStamp equal to the timeStamp of Snowflake min`() {
val snowflake = Snowflake(Instant.DISTANT_PAST)
assertEquals(Snowflake.min.timeStamp, snowflake.timeStamp)
}

@Test
fun `Snowflake created from instant far in the future has timeStamp equal to the timeStamp of Snowflake max`() {
val snowflake = Snowflake(Instant.DISTANT_FUTURE)
assertEquals(Snowflake.max.timeStamp, snowflake.timeStamp)
}

@Test
fun `Snowflake's timeStamp calculates an Instant close to the Instant the Snowflake was created from`() {
val instant = Clock.System.now()
val snowflake = Snowflake(instant)

// snowflake timestamps have a millisecond accuracy -> allow +/-1 millisecond from original instant
val validTimeRange = instant.minus(1, MILLISECOND)..instant.plus(1, MILLISECOND)

assertContains(validTimeRange, snowflake.timeStamp)
}

@Test
fun `min Snowflake's timeMark has passed`() {
assertTrue(Snowflake.min.timeMark.hasPassedNow())
}

@Test
fun `max Snowflake's timeMark has not passed`() {
assertFalse(Snowflake.max.timeMark.hasPassedNow())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ internal class OptionalSnowflakeTest {
val entity = Json.decodeFromString<ValueOptionalEntity>(json)
require(entity.value is OptionalSnowflake.Value)

Assertions.assertEquals(Snowflake(5), entity.value.value)
Assertions.assertEquals(Snowflake(5u), entity.value.value)
}

}
6 changes: 3 additions & 3 deletions core/src/main/kotlin/Util.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal fun String?.toSnowflakeOrNull(): Snowflake? = when {
else -> Snowflake(this)
}

internal fun Long?.toSnowflakeOrNull(): Snowflake? = when {
internal fun ULong?.toSnowflakeOrNull(): Snowflake? = when {
this == null -> null
else -> Snowflake(this)
}
Expand Down Expand Up @@ -215,7 +215,7 @@ internal fun <C : Collection<T>, T : KordEntity> paginateForwards(
* Selects the [Position.Before] the oldest item in the batch.
*/
internal fun <C : Collection<T>, T> paginateBackwards(
start: Snowflake = Snowflake(Long.MAX_VALUE),
start: Snowflake = Snowflake.max,
batchSize: Int,
idSelector: (T) -> Snowflake,
request: suspend (position: Position) -> C
Expand All @@ -226,7 +226,7 @@ internal fun <C : Collection<T>, T> paginateBackwards(
* Selects the [Position.Before] the oldest item in the batch.
*/
internal fun <C : Collection<T>, T : KordEntity> paginateBackwards(
start: Snowflake = Snowflake(Long.MAX_VALUE),
start: Snowflake = Snowflake.max,
batchSize: Int,
request: suspend (position: Position) -> C
): Flow<T> =
Expand Down
3 changes: 1 addition & 2 deletions core/src/main/kotlin/behavior/GuildBehavior.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package dev.kord.core.behavior
import dev.kord.cache.api.query
import dev.kord.common.annotation.DeprecatedSinceKord
import dev.kord.common.annotation.KordExperimental
import dev.kord.common.annotation.KordPreview
import dev.kord.common.entity.DiscordUser
import dev.kord.common.entity.Snowflake
import dev.kord.common.entity.optional.Optional
Expand Down Expand Up @@ -201,7 +200,7 @@ interface GuildBehavior : KordEntity, Strategizable {
*/
val gateway: Gateway?
get() {
val shard = id.value.shr(22) % kord.resources.shards.totalShards.coerceAtLeast(1)
val shard = id.value.shr(22).toLong() % kord.resources.shards.totalShards.coerceAtLeast(1)
return kord.gateway.gateways[shard.toInt()]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import dev.kord.rest.service.RestClient
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
Expand Down Expand Up @@ -67,7 +69,7 @@ interface MessageChannelBehavior : ChannelBehavior, Strategizable {
* The returned flow is lazily executed, any [RequestException] will be thrown on
* [terminal operators](https://kotlinlang.org/docs/reference/coroutines/flow.html#terminal-flow-operators) instead.
*/
val messages: Flow<Message> get() = getMessagesAfter(Snowflake(0))
val messages: Flow<Message> get() = getMessagesAfter(Snowflake.min)

/**
* Requests to get the pinned messages in this channel.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ interface PrivateThreadParentChannelBehavior : ThreadParentChannelBehavior {
* [terminal operators](https://kotlinlang.org/docs/reference/coroutines/flow.html#terminal-flow-operators) instead.
*/
fun getJoinedPrivateArchivedThreads(
before: Snowflake = Snowflake(Long.MAX_VALUE),
before: Snowflake = Snowflake.max,
limit: Int = Int.MAX_VALUE
): Flow<ThreadChannel> {
return supplier.getJoinedPrivateArchivedThreads(id, before, limit)
Expand Down
Loading