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

Add serialization support #2360

Merged
merged 7 commits into from
Mar 17, 2022
Merged

Conversation

gagandeepkalra
Copy link
Contributor

resolves #587

Comment on lines 261 to 262
import SerializationUtil._

Copy link
Member

Choose a reason for hiding this comment

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

This is cool! But maybe we can re-use the SerializableTests in cats-kernel-laws? Example: https://github.com/typelevel/cats/blob/e0784fc0d95b9c4eed0bb1ddce6c69e3fd9a4a4f/tests/src/test/scala/cats/tests/FunctionSuite.scala#L46

Copy link
Member

Choose a reason for hiding this comment

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

@armanbilge Can you please point me to the source code of SerializableTests? I was looking for it but I couldn't find it for some reason.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

That's perfect, let's use that. Thank you.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

SerializableTests doesn't require Arbitrary, I have used law directly

Copy link
Member

Choose a reason for hiding this comment

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

Good catch!

Comment on lines 266 to 284
val ls = List(
IO.pure(42),
IO.raiseError(TestException(42)),
IO.delay(42),
IO.realTime,
IO.monotonic,
IO.executionContext,
IO.pure(42).map(_ + 1),
IO.pure(42).flatMap(_ => IO.pure(42)),
IO.pure(42).attempt,
IO.raiseError(new RuntimeException("Err")).handleErrorWith(_ => IO.pure(42)),
IO.canceled,
IO.pure(42).onCancel(IO.pure(42)),
IO.pure(42).uncancelable,
IO.pure(42).start,
IO.racePair(IO.pure(42), IO.pure(42)),
IO.sleep(0.second),
IO.trace
).map(_.as(42)).zipWithIndex
Copy link
Member

Choose a reason for hiding this comment

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

Just an idea—I wonder if with some strategic overrides in AsyncGenerators you can use it to generate IO without evalOn.

https://github.com/typelevel/cats-effect/blob/series/3.x/kernel-testkit/shared/src/main/scala/cats/effect/kernel/testkit/Generators.scala#L282

Copy link
Member

Choose a reason for hiding this comment

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

Here's how this is done.

override def recursiveGen[B: Arbitrary: Cogen](deeper: GenK[IO]) =
super
.recursiveGen[B](deeper)
.filterNot(
_._1 == "racePair"
) // remove the racePair generator since it reifies nondeterminism, which cannot be law-tested
}

Copy link
Member

Choose a reason for hiding this comment

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

Nice!

Copy link
Member

Choose a reason for hiding this comment

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

You could also make TestContext extend Serializable

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes that was very nice observation, but I couldn’t see it through.

Stuck because somehow ExecutionContextImpl instance is seeping in

Copy link
Member

Choose a reason for hiding this comment

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

I think it's fine, evalOn was never supposed to be serializable anyway :)

Comment on lines 261 to 262
import SerializationUtil._

Copy link
Member

Choose a reason for hiding this comment

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

@armanbilge Can you please point me to the source code of SerializableTests? I was looking for it but I couldn't find it for some reason.

import java.io._
import java.util.Base64

def serialize(`object`: Serializable): Try[String] = {
Copy link
Member

Choose a reason for hiding this comment

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

Use obj instead of object with backticks please.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

deleted this file now

…rialization, switched to Serializable laws
@@ -361,8 +361,9 @@ object pure {
ft.mapK(fk)
}

// todo: MVar is not Serializable, release then update here
Copy link
Contributor Author

Choose a reason for hiding this comment

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

inclusion of racePairtest leads us here, we'd need to update Mvar with serialization

@gagandeepkalra
Copy link
Contributor Author

Thanks for the detailed review,

after adding the property test for IO, I noticed a lot of trait/abstract classes missing serialization, have enhanced those in this very PR

@@ -76,6 +77,22 @@ trait TestInstances extends ParallelFGenerators with OutcomeGenerators with Sync
Arbitrary(generators.generators[A])
}

def arbitraryIOWithoutContextShift[A: Arbitrary: Cogen]: Arbitrary[IO[A]] = {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
def arbitraryIOWithoutContextShift[A: Arbitrary: Cogen]: Arbitrary[IO[A]] = {
def arbitraryIOWithoutEvalOn[A: Arbitrary: Cogen]: Arbitrary[IO[A]] = {

@djspiewak
Copy link
Member

@gagandeepkalra This is awesome work, but would you mind running sbt prePR and committing the results? Scalafmt is unhappy with you. I tried doing it myself, but since you used the series/3.x branch on your local fork, I wasn't able to push

@gagandeepkalra
Copy link
Contributor Author

@gagandeepkalra This is awesome work, but would you mind running sbt prePR and committing the results? Scalafmt is unhappy with you. I tried doing it myself, but since you used the series/3.x branch on your local fork, I wasn't able to push

Hi @djspiewak, sure thing, let me get to this over the weekend.

Also, what do you think about this- #2360 (comment) Should I go there and make this change, then the support would be complete, of course this PR won't have to wait.

@djspiewak
Copy link
Member

@gagandeepkalra I guess I'm a bit confused as to why PureConc is affected at all? Shouldn't we exclusively be testing serializability of IO?

@gagandeepkalra
Copy link
Contributor Author

I need some help, for SyncIO the test fails at this seed: "mWUDuysFx0P-r_sHw_GGVwvm-TcqPoLNs8aOV2g8FuD="

yet I'm not able to reproduce the failure locally, would it help to rerun the case (I don't have the permission to re-run)?

@armanbilge
Copy link
Member

@gagandeepkalra try setting the seed like this:

implicit def parameters = Parameters(seed =
    Seed.fromBase64("mWUDuysFx0P-r_sHw_GGVwvm-TcqPoLNs8aOV2g8FuD=").toOption
  )

@gagandeepkalra
Copy link
Contributor Author

Hi @armanbilge, think I'm making a real silly error somewhere

this is my testcase with your suggestion, the test would not fail wheresoever I put this seed. Please help.

package cats.effect

import cats.kernel.laws.discipline.MonoidTests
import cats.kernel.laws.SerializableLaws.serializable
import cats.laws.discipline.AlignTests
import cats.laws.discipline.arbitrary._
import cats.effect.laws.SyncTests
import cats.syntax.all._
import org.scalacheck.{Arbitrary, Gen, Prop}
import Prop.{Exception, Proof, Result, forAll}
import cats.platform.Platform
import org.scalacheck.rng.Seed
import org.specs2.scalacheck.Parameters
import org.typelevel.discipline.specs2.mutable.Discipline

import scala.util.control.NonFatal

class SyncIOSpec extends BaseSpec with Discipline with SyncIOPlatformSpecification {

  implicit override val defaultParameters: Parameters = Parameters(
    seed = Seed.fromBase64("mWUDuysFx0P-r_sHw_GGVwvm-TcqPoLNs8aOV2g8FuD=").toOption
  )

  "sync io monad" should {
    "serialize" in {
      forAll { (io: SyncIO[Int]) => serializable(io)
      }
    }
  }
}

@armanbilge
Copy link
Member

I'm sorry! I think you ran into this bug maybe?

That's going to make it pretty hard to replicate 😕

@djspiewak
Copy link
Member

djspiewak commented Nov 23, 2021

Here's an example of how you can make it work:

  checkAll(
    "Kleisli[PureConc]",
    GenTemporalTests[Kleisli[TimeT[PureConc[Int, *], *], MiniInt, *], Int]
      .temporal[Int, Int, Int](10.millis)
    // we need to bound this a little tighter because these tests take FOREVER
  )(Parameters(minTestsOk = 25))

Add seed = ... to the Parameters (obviously also replacing the checkAll with the appropriate one) and you're good to go!

@armanbilge
Copy link
Member

@gagandeepkalra we just updated specs2 in #2583 which should have a fix for the seed issue. You can merge the latest series/3.x and try again ... thanks!

@vasilmkd
Copy link
Member

Actually, I probably need to release discipline-specs2?

@armanbilge
Copy link
Member

Oh, are we still only getting specs2 transitively in some subprojects? We should really just fix that :)

@gagandeepkalra
Copy link
Contributor Author

gagandeepkalra commented Nov 28, 2021

okay so I tried skipping specs2 and working directly with Gen to produce a value then call serialize. The test passed, I'm starting to think the seed we get here is not correct.

I added another case for @djspiewak's suggestion, they both generated similar looking value given the seed.

code:

package cats.effect

import cats.kernel.laws.SerializableLaws.serializable
import org.scalacheck.rng.Seed
import org.scalacheck.{Arbitrary, Gen, Prop}
import org.specs2.scalacheck.Parameters
import org.typelevel.discipline.specs2.mutable.Discipline

import scala.util.Try
import scala.util.control.NonFatal

class SyncIOSpec extends BaseSpec with Discipline with SyncIOPlatformSpecification {

  implicit override val defaultParameters: Parameters = Parameters(
    minSize = 10000,
    maxSize = 10000,
    seed = Seed.fromBase64("mWUDuysFx0P-r_sHw_GGVwvm-TcqPoLNs8aOV2g8FuD=").toOption
  )

  "sync io monad" should {

    val arb: Arbitrary[SyncIO[Int]] = implicitly[Arbitrary[SyncIO[Int]]]

    val p: Prop = Prop.forAll(arb.arbitrary)(serializable)(identity, implicitly, implicitly)

    check(p, defaultParameters, defaultFreqMapPretty)

    "test Specific Serialize" in {

      val seed: Try[Seed] = Seed.fromBase64("mWUDuysFx0P-r_sHw_GGVwvm-TcqPoLNs8aOV2g8FuD=")
      val value: SyncIO[Int] = arb.arbitrary.apply(Gen.Parameters.default.withInitialSeed(seed.get), seed.get).get

      verifySerializableOrThrow(value)
      true
    }
  }

  def verifySerializableOrThrow[A](a: A): Unit = {
    import java.io.{ByteArrayInputStream, ByteArrayOutputStream, ObjectInputStream, ObjectOutputStream}

    val baos = new ByteArrayOutputStream()
    val oos = new ObjectOutputStream(baos)
    var ois: ObjectInputStream = null // scalastyle:ignore null
    try {
      oos.writeObject(a)
      oos.close()
      val bais = new ByteArrayInputStream(baos.toByteArray)
      ois = new ObjectInputStream(bais)
      val a2: A = ois.readObject().asInstanceOf[A]
      println("BEFORE:" + a)
      println("AFTER:" + a2)
      ois.close()
    } catch {
      case NonFatal(t) => println(t)
    } finally {
      oos.close()
      if (ois != null) ois.close() // scalastyle:ignore null
    }
  }
}

djspiewak
djspiewak previously approved these changes Mar 13, 2022
Copy link
Member

@djspiewak djspiewak left a comment

Choose a reason for hiding this comment

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

So I had a go at this locally and repeatedly ran and re-ran the tests for a few hours. Never had a single failure. I think any issues here were resolved incidentally by something else in series/3.x along the way.

@vasilmkd
Copy link
Member

Love to see accidentally unblocking a big feature.

@djspiewak
Copy link
Member

Hey! Fun fact: the failures are Scala 3 specific.

@djspiewak
Copy link
Member

djspiewak commented Mar 13, 2022

Even more fun fact, it serializes correctly but doesn't deserialize. In other words, this really smells like an Externalizable that wasn't correctly implemented. Casts summoning spell on @smarter

What we know is that hte SyncIO under test is comprised of the following three stages:

SyncIO.FlatMap[Any, Any]
SyncIO.Pure[Any]
SyncIO.Success[A]

The only thing non-trivial in there is really FlatMap, which contains a Function1.

@djspiewak
Copy link
Member

java.lang.RuntimeException
  | => tat scala.runtime.ModuleSerializationProxy$.scala$runtime$ModuleSerializationProxy$$rethrowRuntime(ModuleSerializationProxy.scala:38)
	at scala.runtime.ModuleSerializationProxy$$anon$1.computeValue(ModuleSerializationProxy.scala:30)
	at scala.runtime.ClassValueCompat$JavaClassValue.computeValue(ClassValueCompat.scala:24)
	at java.base/java.lang.ClassValue.getFromHashMap(ClassValue.java:226)
	at java.base/java.lang.ClassValue.getFromBackup(ClassValue.java:208)
	at java.base/java.lang.ClassValue.get(ClassValue.java:114)
	at scala.runtime.ClassValueCompat.get(ClassValueCompat.scala:33)
	at scala.runtime.ModuleSerializationProxy.readResolve(ModuleSerializationProxy.scala:45)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at java.base/java.io.ObjectStreamClass.invokeReadResolve(ObjectStreamClass.java:1250)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2096)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1594)
	at java.base/java.io.ObjectInputStream.defaultReadFields(ObjectInputStream.java:2355)
	at java.base/java.io.ObjectInputStream.readSerialData(ObjectInputStream.java:2249)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2087)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1594)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:430)
	at cats.effect.SyncIOSpec.liftedTree1$1(SyncIOSpec.scala:247)
	at cats.effect.SyncIOSpec.$init$$$anonfun$159$$anonfun$157$$anonfun$3(SyncIOSpec.scala:252)

@djspiewak
Copy link
Member

Okay, couple problems here. First, accessing MODULE$ is throwing an exception. This is probably why there's a difference between Scala 3 and Scala 2, since this is where you might get some weird initialization order BS going on. Unfortunately, in order to diagnose this further, we really need to know what class we're hitting. The relevant code:

private[runtime] object ModuleSerializationProxy {
  private val instances = new ClassValueCompat[Object] {
    @nowarn("cat=deprecation") // AccessController is deprecated on JDK 17
    def getModule(cls: Class[_]): Object =
      java.security.AccessController.doPrivileged(
        (() => cls.getField("MODULE$").get(null)): PrivilegedExceptionAction[Object])
    override protected def computeValue(cls: Class[_]): Object =
      try getModule(cls)
      catch {
        case e: PrivilegedActionException =>
          rethrowRuntime(e.getCause)
      }
  }

Speaking of which, there's a bug here! (note the above is in the Scala stdlib, shared between 2 and 3). The bug becomes more apparent when I show you rethrowRuntime:

  private def rethrowRuntime(e: Throwable): Object = {
    val cause = e.getCause
    cause match {
      case exception: RuntimeException => throw exception
      case _ => throw new RuntimeException(cause)
    }
  }

So… why are we calling getCause on the results of getCause? I'm willing to bet that we should not be.

At any rate, I'm relatively certain this is definitively problems upstream, so I think the right approach here is to make the serialization tests Scala 2 exclusive.

@smarter
Copy link
Contributor

smarter commented Mar 13, 2022

Scala 3 should behave like Scala 2 for object serialization (scala/scala#7297 / scala/scala3#5775), but we emit all objects as extends Serializable for convenience and lambdas in objects are emitted as instance methods of the module class, so serializing/deserializing them will trigger the ModuleSerializationProxy codepath unlike Scala 2: https://github.com/lampepfl/dotty/blob/5626f2512d1451bd7d933c26099f82b993e68f04/compiler/src/dotty/tools/backend/jvm/BCodeSkelBuilder.scala#L250-L268

@smarter
Copy link
Contributor

smarter commented Mar 13, 2022

So… why are we calling getCause on the results of getCause? I'm willing to bet that we should not be.

Yeah that seems weird, could you open an issue at scala/scala and ping Jason who wrote that code originally? Meanwhile, I think you could redefine ModuleSerializationProxy in your own project to shadow the one coming from scala-library and add a println to figure out what the name of the deserialized class is.

@djspiewak
Copy link
Member

djspiewak commented Mar 13, 2022

scala/bug#12553

I'll see about shadowing to see if we can get the actual class.

@djspiewak
Copy link
Member

Fun fact: you can't really shadow it because it's not on the classpath. So you can't get it to compile.

@smarter
Copy link
Contributor

smarter commented Mar 13, 2022

Not sure I get it, I think you can copy-paste ModuleSerializationProxy.scala and ClassValueCompat.scala in your project and it should compile. But it might be easier to just fire up a debugger and break on rethrowRuntime.

@djspiewak
Copy link
Member

@smarter Here's something cool: if I shadow everything needed to make the shadowing compile (https://gist.github.com/423c26f4ed8147d0e97b03ee3c406d03), the exception disappears and both serialization and deserialization succeed.

@smarter
Copy link
Contributor

smarter commented Mar 14, 2022

Huh. Before going deeper into the rabbit hole, can you try using a more recent version of Scala 3? (latest nightly is scalaVersion := "3.2.0-RC1-bin-20220308-29073f1-NIGHTLY")

@smarter
Copy link
Contributor

smarter commented Mar 14, 2022

... actually latest nightly is scalaVersion := "3.1.3-RC1-bin-20220311-895598f-NIGHTLY", I forgot we reverted the version bump.

@djspiewak
Copy link
Member

@smarter Same issue :-(

@djspiewak
Copy link
Member

I think we'll probably just disable the tests on Scala 3 for the time being. We'll make a ticket to come back and try again once scala/scala#9970 is released as part of a Scala 3 build. I just don't want to keep this PR languishing forever. I really do wish we had a more minimal reproduction though. Any thoughts on whether this would also be worth filing upstream on Dotty?

@smarter
Copy link
Contributor

smarter commented Mar 14, 2022

So I tried the copy-paste ModuleSerializationProxy.scala trick myself and was able to get a better exception after removing the extra getCause:

Class scala.runtime.ModuleSerializationProxy$ can not access a member of class cats.effect.SyncIO$RealTime$ with modifiers "public static final" (Reflection.java:102)

And the reason the JVM is unhappy is that SyncIO$RealTime$ is emitted as a private class because it's private in the source unlike scala 2 which iirc always emits all classes (even private inner classes) as public (not even package-private!). So either we need to emit all classes as public too or we need to add a setAccessible in ModuleSerializationProxy.

@smarter
Copy link
Contributor

smarter commented Mar 14, 2022

The trouble with using setAccessible is that it will likely stop working when attempting to use the java module system, so we might have no choice but to make every class public.

smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 14, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 14, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
@smarter
Copy link
Contributor

smarter commented Mar 14, 2022

scala/scala3#14686

@djspiewak
Copy link
Member

So I tried the copy-paste ModuleSerializationProxy.scala trick myself and was able to get a better exception after removing the extra getCause:

Thank you for doing that! It's absolutely wild that it didn't fail for me when I did that exact series of steps.

And the reason the JVM is unhappy is that SyncIO$RealTime$ is emitted as a private class because it's private in the source unlike scala 2 which iirc always emits all classes (even private inner classes) as public (not even package-private!). So either we need to emit all classes as public too or we need to add a setAccessible in ModuleSerializationProxy.

Ahhhhhhhhh. Okay that makes sense. That definitely seems like something that will end up being a blocker for things involving serialization (coughs Spark), particularly given the fact that lambdas make it very easy to accidentally close over state which would trigger this path.

smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 14, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 14, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 14, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
@djspiewak djspiewak merged commit 7b601e6 into typelevel:series/3.x Mar 17, 2022
smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 21, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 21, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
smarter added a commit to dotty-staging/dotty that referenced this pull request Mar 21, 2022
This aligns our behavior with Scala 2 and fixes the issue encountered in
typelevel/cats-effect#2360 (comment).

Alternatively, we could change ModuleSerializationProxy upstream to call
`setAccessible(true)` on the MODULE$ field, but this wouldn't work if the object
in question is inside a Java 9+ module.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Make IO serializable
5 participants