From 8ebf55983eefa5ba390348db45d39bc5df8fe90b Mon Sep 17 00:00:00 2001 From: Dragana Date: Thu, 9 Jun 2022 17:08:31 +0200 Subject: [PATCH] Add 2022-final --- .../concpar22final01/.gitignore | 17 + .../concpar22final01/assignment.sbt | 5 + .../concpar22final01/build.sbt | 11 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final01/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final01/project/plugins.sbt | 2 + .../scala/concpar22final01/Problem1.scala | 65 +++ .../src/main/scala/concpar22final01/lib.scala | 81 +++ .../concpar22final01/Problem1Suite.scala | 491 ++++++++++++++++++ .../concpar22final02/.gitignore | 17 + .../concpar22final02/assignment.sbt | 5 + .../concpar22final02/build.sbt | 11 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final02/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final02/project/plugins.sbt | 2 + .../concpar22final02/AbstractBarrier.scala | 11 + .../main/scala/concpar22final02/Barrier.scala | 16 + .../scala/concpar22final02/ImageLib.scala | 47 ++ .../scala/concpar22final02/Problem2.scala | 29 ++ .../instrumentation/Monitor.scala | 32 ++ .../instrumentation/Stats.scala | 19 + .../concpar22final02/Problem2Suite.scala | 413 +++++++++++++++ .../instrumentation/MockedMonitor.scala | 57 ++ .../instrumentation/SchedulableBarrier.scala | 20 + .../instrumentation/Scheduler.scala | 294 +++++++++++ .../instrumentation/TestHelper.scala | 127 +++++ .../instrumentation/TestUtils.scala | 14 + .../concpar22final03/.gitignore | 17 + .../concpar22final03/assignment.sbt | 5 + .../concpar22final03/build.sbt | 11 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final03/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final03/project/plugins.sbt | 2 + .../scala/concpar22final03/Economics.scala | 44 ++ .../scala/concpar22final03/Problem3.scala | 52 ++ .../concpar22final03/EconomicsTest.scala | 98 ++++ .../concpar22final03/Problem3Suite.scala | 201 +++++++ .../concpar22final04/.gitignore | 17 + .../concpar22final04/assignment.sbt | 5 + .../concpar22final04/build.sbt | 23 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final04/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final04/project/plugins.sbt | 2 + .../scala/concpar22final04/Problem4.scala | 220 ++++++++ .../concpar22final04/Problem4Suite.scala | 361 +++++++++++++ previous-exams/2022-final/concpar22final01.md | 102 ++++ .../2022-final/concpar22final01/.gitignore | 17 + .../concpar22final01/assignment.sbt | 5 + .../2022-final/concpar22final01/build.sbt | 11 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final01/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final01/project/plugins.sbt | 2 + .../scala/concpar22final01/Problem1.scala | 31 ++ .../src/main/scala/concpar22final01/lib.scala | 81 +++ .../concpar22final01/Problem1Suite.scala | 491 ++++++++++++++++++ previous-exams/2022-final/concpar22final02.md | 38 ++ .../2022-final/concpar22final02/.gitignore | 17 + .../concpar22final02/assignment.sbt | 5 + .../2022-final/concpar22final02/build.sbt | 11 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final02/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final02/project/plugins.sbt | 2 + .../concpar22final02/AbstractBarrier.scala | 11 + .../main/scala/concpar22final02/Barrier.scala | 7 + .../scala/concpar22final02/ImageLib.scala | 47 ++ .../scala/concpar22final02/Problem2.scala | 12 + .../instrumentation/Monitor.scala | 32 ++ .../instrumentation/Stats.scala | 19 + .../concpar22final02/Problem2Suite.scala | 413 +++++++++++++++ .../instrumentation/MockedMonitor.scala | 57 ++ .../instrumentation/SchedulableBarrier.scala | 20 + .../instrumentation/Scheduler.scala | 294 +++++++++++ .../instrumentation/TestHelper.scala | 127 +++++ .../instrumentation/TestUtils.scala | 14 + previous-exams/2022-final/concpar22final03.md | 37 ++ .../2022-final/concpar22final03/.gitignore | 17 + .../concpar22final03/assignment.sbt | 5 + .../2022-final/concpar22final03/build.sbt | 11 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final03/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final03/project/plugins.sbt | 2 + .../scala/concpar22final03/Economics.scala | 44 ++ .../scala/concpar22final03/Problem3.scala | 38 ++ .../concpar22final03/EconomicsTest.scala | 98 ++++ .../concpar22final03/Problem3Suite.scala | 201 +++++++ previous-exams/2022-final/concpar22final04.md | 98 ++++ .../2022-final/concpar22final04/.gitignore | 17 + .../concpar22final04/assignment.sbt | 5 + .../2022-final/concpar22final04/build.sbt | 23 + .../project/CourseraStudent.scala | 212 ++++++++ .../project/MOOCSettings.scala | 51 ++ .../project/StudentTasks.scala | 150 ++++++ .../concpar22final04/project/build.properties | 1 + .../project/buildSettings.sbt | 5 + .../concpar22final04/project/plugins.sbt | 2 + .../scala/concpar22final04/Problem4.scala | 179 +++++++ .../concpar22final04/Problem4Suite.scala | 361 +++++++++++++ previous-exams/2022-final/spotify.jpg | Bin 0 -> 343099 bytes 119 files changed, 9200 insertions(+) create mode 100644 previous-exams/2022-final-solutions/concpar22final01/.gitignore create mode 100644 previous-exams/2022-final-solutions/concpar22final01/assignment.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final01/build.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final01/project/build.properties create mode 100644 previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/.gitignore create mode 100644 previous-exams/2022-final-solutions/concpar22final02/assignment.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final02/build.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/project/build.properties create mode 100644 previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/.gitignore create mode 100644 previous-exams/2022-final-solutions/concpar22final03/assignment.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final03/build.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/project/build.properties create mode 100644 previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final04/.gitignore create mode 100644 previous-exams/2022-final-solutions/concpar22final04/assignment.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final04/build.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final04/project/build.properties create mode 100644 previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt create mode 100644 previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala create mode 100644 previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala create mode 100644 previous-exams/2022-final/concpar22final01.md create mode 100644 previous-exams/2022-final/concpar22final01/.gitignore create mode 100644 previous-exams/2022-final/concpar22final01/assignment.sbt create mode 100644 previous-exams/2022-final/concpar22final01/build.sbt create mode 100644 previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final/concpar22final01/project/StudentTasks.scala create mode 100644 previous-exams/2022-final/concpar22final01/project/build.properties create mode 100644 previous-exams/2022-final/concpar22final01/project/buildSettings.sbt create mode 100644 previous-exams/2022-final/concpar22final01/project/plugins.sbt create mode 100644 previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala create mode 100644 previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala create mode 100644 previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala create mode 100644 previous-exams/2022-final/concpar22final02.md create mode 100644 previous-exams/2022-final/concpar22final02/.gitignore create mode 100644 previous-exams/2022-final/concpar22final02/assignment.sbt create mode 100644 previous-exams/2022-final/concpar22final02/build.sbt create mode 100644 previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final/concpar22final02/project/StudentTasks.scala create mode 100644 previous-exams/2022-final/concpar22final02/project/build.properties create mode 100644 previous-exams/2022-final/concpar22final02/project/buildSettings.sbt create mode 100644 previous-exams/2022-final/concpar22final02/project/plugins.sbt create mode 100644 previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala create mode 100644 previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala create mode 100644 previous-exams/2022-final/concpar22final03.md create mode 100644 previous-exams/2022-final/concpar22final03/.gitignore create mode 100644 previous-exams/2022-final/concpar22final03/assignment.sbt create mode 100644 previous-exams/2022-final/concpar22final03/build.sbt create mode 100644 previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final/concpar22final03/project/StudentTasks.scala create mode 100644 previous-exams/2022-final/concpar22final03/project/build.properties create mode 100644 previous-exams/2022-final/concpar22final03/project/buildSettings.sbt create mode 100644 previous-exams/2022-final/concpar22final03/project/plugins.sbt create mode 100644 previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala create mode 100644 previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala create mode 100644 previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala create mode 100644 previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala create mode 100644 previous-exams/2022-final/concpar22final04.md create mode 100644 previous-exams/2022-final/concpar22final04/.gitignore create mode 100644 previous-exams/2022-final/concpar22final04/assignment.sbt create mode 100644 previous-exams/2022-final/concpar22final04/build.sbt create mode 100644 previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala create mode 100644 previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala create mode 100644 previous-exams/2022-final/concpar22final04/project/StudentTasks.scala create mode 100644 previous-exams/2022-final/concpar22final04/project/build.properties create mode 100644 previous-exams/2022-final/concpar22final04/project/buildSettings.sbt create mode 100644 previous-exams/2022-final/concpar22final04/project/plugins.sbt create mode 100644 previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala create mode 100644 previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala create mode 100644 previous-exams/2022-final/spotify.jpg diff --git a/previous-exams/2022-final-solutions/concpar22final01/.gitignore b/previous-exams/2022-final-solutions/concpar22final01/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final-solutions/concpar22final01/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final01/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final-solutions/concpar22final01/build.sbt b/previous-exams/2022-final-solutions/concpar22final01/build.sbt new file mode 100644 index 0000000..beb0e5c --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/build.sbt @@ -0,0 +1,11 @@ +course := "concpar" +assignment := "concpar22final01" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/build.properties b/previous-exams/2022-final-solutions/concpar22final01/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala new file mode 100644 index 0000000..d555c0d --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/Problem1.scala @@ -0,0 +1,65 @@ +package concpar22final01 + +trait Problem1 extends Lib: + + class DLLCombinerImplementation extends DLLCombiner: + + // Copies every other Integer element of data array, starting from the first (index 0), up to the middle + def task1(data: Array[Int]) = task { + + var current = first + var i = 0 + while current != null && i < size / 2 do + data(i) = current.value + i += 2 + current = current.getNext2 + } + + // Copies every other Integer element of data array, starting from the second, up to the middle + def task2(data: Array[Int]) = task { + + var current = second + var i = 1 + while current != null && i < size / 2 do + data(i) = current.value + i += 2 + current = current.getNext2 + } + + // Copies every other Integer element of data array, starting from the second to last, up to the middle + def task3(data: Array[Int]) = task { + + var current = secondToLast + var i = size - 2 + while current != null && i >= size / 2 do + data(i) = current.value + i -= 2 + current = current.getPrevious2 + } + + // Copies every other Integer element of data array, starting from the last, up to the middle + // This is executed on the current thread. + def task4(data: Array[Int]) = + + var current = last + var i = size - 1 + while current != null && i >= size / 2 do + data(i) = current.value + i -= 2 + current = current.getPrevious2 + + def result(): Array[Int] = + val data = new Array[Int](size) + + val t1 = task1(data) + val t2 = task2(data) + val t3 = task3(data) + + task4(data) + + t1.join() + t2.join() + t3.join() + + + data diff --git a/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala new file mode 100644 index 0000000..6d9d6ee --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/src/main/scala/concpar22final01/lib.scala @@ -0,0 +1,81 @@ +package concpar22final01 + +import java.util.concurrent.* +import scala.util.DynamicVariable + +trait Lib: + class Node(val value: Int): + protected var next: Node = null // null for last node. + protected var next2: Node = null // null for last node. + protected var previous: Node = null // null for first node. + protected var previous2: Node = null // null for first node. + + def getNext: Node = next // do NOT use in the result method + def getNext2: Node = next2 + def getPrevious: Node = previous // do NOT use in the result method + def getPrevious2: Node = previous2 + + def setNext(n: Node): Unit = next = n + def setNext2(n: Node): Unit = next2 = n + def setPrevious(n: Node): Unit = previous = n + def setPrevious2(n: Node): Unit = previous2 = n + + // Simplified Combiner interface + // Implements methods += and combine + // Abstract methods should be implemented in subclasses + abstract class DLLCombiner: + var first: Node = null // null for empty lists. + var last: Node = null // null for empty lists. + + var second: Node = null // null for empty lists. + var secondToLast: Node = null // null for empty lists. + + var size: Int = 0 + + // Adds an Integer to this array combiner. + def +=(elem: Int): Unit = + val node = new Node(elem) + if size == 0 then + first = node + last = node + size = 1 + else + last.setNext(node) + node.setPrevious(last) + node.setPrevious2(last.getPrevious) + if size > 1 then last.getPrevious.setNext2(node) + else second = node + secondToLast = last + last = node + size += 1 + + // Combines this array combiner and another given combiner in constant O(1) complexity. + def combine(that: DLLCombiner): DLLCombiner = + if this.size == 0 then that + else if that.size == 0 then this + else + this.last.setNext(that.first) + this.last.setNext2(that.first.getNext) + if this.last.getPrevious != null then + this.last.getPrevious.setNext2(that.first) // important + + that.first.setPrevious(this.last) + that.first.setPrevious2(this.last.getPrevious) + if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important + + if this.size == 1 then second = that.first + + this.size = this.size + that.size + this.last = that.last + this.secondToLast = that.secondToLast + + this + + def task1(data: Array[Int]): ForkJoinTask[Unit] + def task2(data: Array[Int]): ForkJoinTask[Unit] + def task3(data: Array[Int]): ForkJoinTask[Unit] + def task4(data: Array[Int]): Unit + + def result(): Array[Int] + + def task[T](body: => T): ForkJoinTask[T] diff --git a/previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala b/previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala new file mode 100644 index 0000000..a650072 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala @@ -0,0 +1,491 @@ +package concpar22final01 + +import java.util.concurrent.* +import scala.util.DynamicVariable + +class Problem1Suite extends AbstractProblem1Suite: + + test("[Public] fetch simple result without combining (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 7 + combiner1 += 2 + combiner1 += 3 + combiner1 += 8 + combiner1 += 1 + combiner1 += 2 + combiner1 += 3 + combiner1 += 8 + + val result = combiner1.result() + val array = Array(7, 2, 3, 8, 1, 2, 3, 8) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + test("[Public] fetch result without combining (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 7 + combiner1 += 2 + combiner1 += 3 + combiner1 += 8 + combiner1 += 1 + + val result = combiner1.result() + val array = Array(7, 2, 3, 8, 1) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + test("[Public] fetch result after simple combining (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 7 + combiner1 += 2 + + val combiner2 = new DLLCombinerTest + combiner2 += 3 + combiner2 += 8 + + val combiner3 = new DLLCombinerTest + combiner3 += 1 + combiner3 += 9 + + val combiner4 = new DLLCombinerTest + combiner4 += 3 + combiner4 += 2 + + val result = combiner1.combine(combiner2).combine(combiner3).combine(combiner4).result() + val array = Array(7, 2, 3, 8, 1, 9, 3, 2) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + test("[Public] fetch result - small combiner (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 4 + combiner1 += 2 + combiner1 += 6 + + val result = combiner1.result() + val array = Array(4, 2, 6) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + + // (25+) 15 / 250 points for correct implementation, don't check parallelism + test("[Correctness] fetch result - simple combiners (2pts)") { + assertCorrectnessSimple() + } + + test("[Correctness] fetch result - small combiners (3pts)") { + assertCorrectnessBasic() + } + + test("[Correctness] fetch result - small combiners after combining (5pts)") { + assertCorrectnessCombined() + } + + test("[Correctness] fetch result - large combiners (5pts)") { + assertCorrectnessLarge() + } + + def assertCorrectnessSimple() = simpleCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + def assertCorrectnessBasic() = basicCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + def assertCorrectnessCombined() = + combinedCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + def assertCorrectnessLarge() = largeCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + // (25+15+) 25 / 250 points for correct parallel implementation, don't check if it's exactly 1/4 of the array per task + private var count = 0 + private val expected = 3 + + override def task[T](body: => T): ForkJoinTask[T] = + count += 1 + scheduler.value.schedule(body) + + test("[TaskCount] number of newly created tasks should be 3 (5pts)") { + assertTaskCountSimple() + } + + test("[TaskCount] fetch result and check parallel - simple combiners (5pts)") { + assertTaskCountSimple() + assertCorrectnessSimple() + } + + test("[TaskCount] fetch result and check parallel - small combiners (5pts)") { + assertTaskCountSimple() + assertCorrectnessBasic() + } + + test("[TaskCount] fetch result and check parallel - small combiners after combining (5pts)") { + assertTaskCountSimple() + assertCorrectnessCombined() + } + + test("[TaskCount] fetch result and check parallel - large combiners (5pts)") { + assertTaskCountSimple() + assertCorrectnessLarge() + } + + def assertTaskCountSimple(): Unit = + simpleCombiners.foreach(elem => assertTaskCount(elem._1, elem._2)) + + def assertTaskCount(combiner: DLLCombinerTest, array: Array[Int]): Unit = + try + count = 0 + build(combiner, array) + combiner.result() + assertEquals( + count, + expected, { + s"ERROR: Expected $expected instead of $count calls to `task(...)`" + } + ) + finally count = 0 + + // (25+15+25+) 50 / 250 points for correct implementation that uses only next2 and previous2, and not next and previous + test("[Skip2] fetch parallel result and check skip2 - simple combiners (10pts)") { + assertTaskCountSimple() + assertSkipSimple() + assertCorrectnessSimple() + } + + test("[Skip2] fetch result and check skip2 - simple combiners (10pts)") { + assertSkipSimple() + assertCorrectnessSimple() + } + + test("[Skip2] fetch result and check skip2 - small combiners (10pts)") { + assertSkipSimple() + assertCorrectnessBasic() + } + + test("[Skip2] fetch result and check skip2 - small combiners after combining (10pts)") { + assertSkipSimple() + assertCorrectnessCombined() + } + + test("[Skip2] fetch result and check skip2 - large combiners (10pts)") { + assertSkipSimple() + assertCorrectnessLarge() + } + + def assertSkipSimple(): Unit = simpleCombiners.foreach(elem => assertSkip(elem._1, elem._2)) + + def assertSkip(combiner: DLLCombinerTest, array: Array[Int]): Unit = + build(combiner, array) + combiner.result() + assertEquals( + combiner.nonSkipped, + false, { + s"ERROR: Calls to 'next' and 'previous' are not allowed! You should only use 'next2` and 'previous2' in your solution." + } + ) + + // (25+15+25+50+) 75 / 250 points for correct parallel implementation, exactly 1/4 of the array per task + test("[TaskFairness] each task should compute 1/4 of the result (15pts)") { + assertTaskFairness(simpleCombiners.unzip._1) + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - simple combiners (15pts)" + ) { + assertTaskFairness(simpleCombiners.unzip._1) + assertCorrectnessSimple() + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners (15pts)" + ) { + assertTaskFairness(basicCombiners.unzip._1) + assertCorrectnessBasic() + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners after combining (15pts)" + ) { + assertTaskFairness(combinedCombiners.unzip._1) + assertCorrectnessCombined() + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - large combiners (15pts)" + ) { + assertTaskFairness(largeCombiners.unzip._1) + assertCorrectnessLarge() + } + + def assertTaskFairness(combiners: List[DLLCombiner]): Unit = + def assertNewTaskFairness(combiner: DLLCombiner, task: ForkJoinTask[Unit], data: Array[Int]) = + var count = 0 + var expected = combiner.size / 4 + task.join + count = data.count(elem => elem != 0) + assert((count - expected).abs <= 1) + + def assertMainTaskFairness(combiner: DLLCombiner, task: Unit, data: Array[Int]) = + var count = 0 + var expected = combiner.size / 4 + count = data.count(elem => elem != 0) + assert((count - expected).abs <= 1) + + combiners.foreach { elem => + var data = Array.fill(elem.size)(0) + assertNewTaskFairness(elem, elem.task1(data), data) + + data = Array.fill(elem.size)(0) + assertNewTaskFairness(elem, elem.task2(data), data) + + data = Array.fill(elem.size)(0) + assertNewTaskFairness(elem, elem.task3(data), data) + + data = Array.fill(elem.size)(0) + assertMainTaskFairness(elem, elem.task4(data), data) + } + + // (25+15+25+50+75+) 60 / 250 points for correct parallel implementation, exactly 1/4 of the array per task, exactly the specified quarter + + test( + "[TaskPrecision] each task should compute specified 1/4 of the result - simple combiners (10pts)" + ) { + assertTaskPrecision(simpleCombiners) + } + + test( + "[TaskPrecision] task1 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision1(simpleCombiners) + } + + test( + "[TaskPrecision] task2 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision2(simpleCombiners) + } + + test( + "[TaskPrecision] task3 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision3(simpleCombiners) + } + + test( + "[TaskPrecision] task4 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision4(simpleCombiners) + } + + test( + "[TaskPrecision] each task should compute specified 1/4 of the result - other combiners (30pts)" + ) { + assertTaskPrecision(basicCombiners) + assertTaskPrecision(combinedCombiners) + assertTaskPrecision(largeCombiners) + } + + def assertTaskPrecision(combiners: List[(DLLCombiner, Array[Int])]): Unit = + assertTaskPrecision1(combiners) + assertTaskPrecision2(combiners) + assertTaskPrecision3(combiners) + assertTaskPrecision4(combiners) + + def assertTaskPrecision1(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task1 = elem._1.task1(data) + task1.join + Range(0, elem._1.size).foreach(i => + (if i < elem._1.size / 2 - 1 && i % 2 == 0 then ref(i) = elem._2(i)) + ) + assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i))) + } + + def assertTaskPrecision2(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task2 = elem._1.task2(data) + task2.join + Range(0, elem._1.size).foreach(i => + (if i < elem._1.size / 2 - 1 && i % 2 == 1 then ref(i) = elem._2(i)) + ) + assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i))) + } + + def assertTaskPrecision3(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task3 = elem._1.task3(data) + task3.join + Range(0, elem._1.size).foreach(i => + (if i > elem._1.size / 2 + 1 && i % 2 == elem._1.size % 2 then ref(i) = elem._2(i)) + ) + assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i))) + } + + def assertTaskPrecision4(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task4 = elem._1.task4(data) + Range(0, elem._1.size).foreach(i => + (if i > elem._1.size / 2 + 1 && i % 2 != elem._1.size % 2 then ref(i) = elem._2(i)) + ) + assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i))) + } + +trait AbstractProblem1Suite extends munit.FunSuite with LibImpl: + + def simpleCombiners = buildSimpleCombiners() + def basicCombiners = buildBasicCombiners() + def combinedCombiners = buildCombinedCombiners() + def largeCombiners = buildLargeCombiners() + + def buildSimpleCombiners() = + val simpleCombiners = List( + (new DLLCombinerTest, Array(4, 2, 6, 1, 5, 4, 3, 5, 6, 3, 4, 5, 6, 3, 4, 5)), + (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 1, 1, 1, 1, 1, 1, 1, 2, 3, 2)), + (new DLLCombinerTest, Array.fill(16)(5)) + ) + simpleCombiners.foreach(elem => build(elem._1, elem._2)) + simpleCombiners + + def buildBasicCombiners() = + val basicCombiners = List( + (new DLLCombinerTest, Array(4, 2, 6)), + (new DLLCombinerTest, Array(4, 1, 6)), + (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 11, 12, 5, 14, 15, 1, 17, 23)), + (new DLLCombinerTest, Array(7, 2, 9, 9, 3, 2, 11, 12, 13, 14, 15, 16, 17, 22)), + (new DLLCombinerTest, Array.fill(16)(7)), + (new DLLCombinerTest, Array.fill(16)(4)), + (new DLLCombinerTest, Array.fill(5)(3)), + (new DLLCombinerTest, Array.fill(5)(7)), + (new DLLCombinerTest, Array.fill(5)(4)) + ) + basicCombiners.foreach(elem => build(elem._1, elem._2)) + basicCombiners + + def buildCombinedCombiners() = + var combinedCombiners = List[(DLLCombiner, Array[Int])]() + + Range(1, 10).foreach { n => + val array = basicCombiners.filter(elem => elem._1.size == n).foldLeft(Array[Int]()) { + (acc, i) => acc ++ i._2 + } + val empty: DLLCombiner = new DLLCombinerTest + val combiner = basicCombiners.filter(elem => elem._1.size == n).map(_._1).foldLeft(empty) { + (acc, c) => acc.combine(c) + } + + combinedCombiners = combinedCombiners :+ (combiner, array) + } + combinedCombiners + + def buildLargeCombiners() = + val largeCombiners = List( + (new DLLCombinerTest, Array.fill(1321)(4) ++ Array.fill(1322)(7)), + (new DLLCombinerTest, Array.fill(1341)(2) ++ Array.fill(1122)(5)), + ( + new DLLCombinerTest, + Array.fill(1321)(4) ++ Array.fill(1322)(7) ++ Array.fill(321)(4) ++ Array.fill(322)(7) + ), + (new DLLCombinerTest, Array.fill(992321)(4) ++ Array.fill(99322)(7)), + (new DLLCombinerTest, Array.fill(953211)(4) ++ Array.fill(999322)(1)) + ) + largeCombiners.foreach(elem => build(elem._1, elem._2)) + largeCombiners + + def build(combiner: DLLCombinerTest, array: Array[Int]): DLLCombinerTest = + array.foreach(elem => combiner += elem) + combiner + + def compare(combiner: DLLCombiner, array: Array[Int]): Boolean = + val result = combiner.result() + Range(0, array.size).forall(i => array(i) == result(i)) + + def buildAndCompare(combiner: DLLCombinerTest, array: Array[Int]): Boolean = + array.foreach(elem => combiner += elem) + val result = combiner.result() + Range(0, array.size).forall(i => array(i) == result(i)) + +trait LibImpl extends Problem1: + + val forkJoinPool = new ForkJoinPool + + abstract class TaskScheduler: + def schedule[T](body: => T): ForkJoinTask[T] + + class DefaultTaskScheduler extends TaskScheduler: + def schedule[T](body: => T): ForkJoinTask[T] = + val t = new RecursiveTask[T]: + def compute = body + Thread.currentThread match + case wt: ForkJoinWorkerThread => + t.fork() + case _ => + forkJoinPool.execute(t) + t + + val scheduler = new DynamicVariable[TaskScheduler](new DefaultTaskScheduler) + + def task[T](body: => T): ForkJoinTask[T] = scheduler.value.schedule(body) + + class NodeTest(val v: Int, val myCombiner: DLLCombinerTest) extends Node(v): + override def getNext: Node = + myCombiner.nonSkipped = true + next + override def getNext2: Node = next2 + override def getPrevious: Node = + myCombiner.nonSkipped = true + previous + override def getPrevious2: Node = previous2 + override def setNext(n: Node): Unit = next = n + override def setNext2(n: Node): Unit = next2 = n + override def setPrevious(n: Node): Unit = previous = n + override def setPrevious2(n: Node): Unit = previous2 = n + + class DLLCombinerTest extends DLLCombinerImplementation: + var nonSkipped = false + override def result(): Array[Int] = + nonSkipped = false + super.result() + override def +=(elem: Int): Unit = + val node = new NodeTest(elem, this) + if size == 0 then + first = node + last = node + size = 1 + else + last.setNext(node) + node.setPrevious(last) + node.setPrevious2(last.getPrevious) + if size > 1 then last.getPrevious.setNext2(node) + else second = node + secondToLast = last + last = node + size += 1 + override def combine(that: DLLCombiner): DLLCombiner = + if this.size == 0 then that + else if that.size == 0 then this + else + this.last.setNext(that.first) + this.last.setNext2(that.first.getNext) + if this.last.getPrevious != null then + this.last.getPrevious.setNext2(that.first) // important + + that.first.setPrevious(this.last) + that.first.setPrevious2(this.last.getPrevious) + if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important + + if this.size == 1 then second = that.first + + this.size = this.size + that.size + this.last = that.last + this.secondToLast = that.secondToLast + + this diff --git a/previous-exams/2022-final-solutions/concpar22final02/.gitignore b/previous-exams/2022-final-solutions/concpar22final02/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final-solutions/concpar22final02/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final02/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final-solutions/concpar22final02/build.sbt b/previous-exams/2022-final-solutions/concpar22final02/build.sbt new file mode 100644 index 0000000..61e5a6e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/build.sbt @@ -0,0 +1,11 @@ +course := "concpar" +assignment := "concpar22final02" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "0.7.26" % Test + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/build.properties b/previous-exams/2022-final-solutions/concpar22final02/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala new file mode 100644 index 0000000..decdbc9 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala @@ -0,0 +1,11 @@ +package concpar22final02 + +import instrumentation.Monitor + +abstract class AbstractBarrier(val numThreads: Int) extends Monitor: + + var count = numThreads + + def awaitZero(): Unit + + def countDown(): Unit diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala new file mode 100644 index 0000000..68066d2 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Barrier.scala @@ -0,0 +1,16 @@ +package concpar22final02 + +class Barrier(numThreads: Int) extends AbstractBarrier(numThreads): + + + def awaitZero(): Unit = + synchronized { + while count > 0 do wait() + } + + + def countDown(): Unit = + synchronized { + count -= 1 + if count <= 0 then notifyAll() + } diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala new file mode 100644 index 0000000..0f538ea --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala @@ -0,0 +1,47 @@ +package concpar22final02 + +import scala.collection.mutable.ArrayBuffer + +class ImageLib(size: Int): + + val buffer1: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(1) + val buffer2: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(0) + + enum Filter(val kernel: Array[Array[Int]]): + case Outline extends Filter(Array(Array(-1, -1, -1), Array(-1, 8, -1), Array(-1, -1, -1))) + case Sharpen extends Filter(Array(Array(0, -1, 0), Array(-1, 5, -1), Array(0, -1, 0))) + case Emboss extends Filter(Array(Array(-2, -1, 0), Array(-1, 1, 1), Array(0, 1, 2))) + case Identity extends Filter(Array(Array(0, 0, 0), Array(0, 1, 0), Array(0, 0, 0))) + + def init(input: ArrayBuffer[ArrayBuffer[Int]]) = + for i <- 0 to size - 1 do + for j <- 0 to size - 1 do + buffer1(i)(j) = input(i)(j) + + def computeConvolution( + kernel: Array[Array[Int]], + input: ArrayBuffer[ArrayBuffer[Int]], + row: Int, + column: Int + ): Int = + + val displacement = Array(-1, 0, 1) + var output = 0 + + for i <- 0 to 2 do + for j <- 0 to 2 do + val newI = row + displacement(i) + val newJ = column + displacement(j) + if newI < 0 || newI >= size || newJ < 0 || newJ >= size then output += 0 + else output += (kernel(i)(j) * input(newI)(newJ)) + + output + + def applyFilter( + kernel: Array[Array[Int]], + input: ArrayBuffer[ArrayBuffer[Int]], + output: ArrayBuffer[ArrayBuffer[Int]], + row: Int + ): Unit = + for i <- 0 to input(row).size - 1 do + output(row)(i) = computeConvolution(kernel, input, row, i) diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala new file mode 100644 index 0000000..84e63b5 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/Problem2.scala @@ -0,0 +1,29 @@ +package concpar22final02 + +import java.util.concurrent.atomic.AtomicInteger +import scala.collection.mutable.ArrayBuffer + +class Problem2(imageSize: Int, numThreads: Int, numFilters: Int): + + val barrier: ArrayBuffer[Barrier] = ArrayBuffer.fill(numFilters)(Barrier(numThreads)) + + val imageLib: ImageLib = ImageLib(imageSize) + + + def imagePipeline( + filters: Array[imageLib.Filter], + rows: Array[Int] + ): ArrayBuffer[ArrayBuffer[Int]] = + for i <- 0 to filters.size - 1 do + for j <- 0 to rows.size - 1 do + if i % 2 == 0 then + imageLib.applyFilter(filters(i).kernel, imageLib.buffer1, imageLib.buffer2, rows(j)) + else + imageLib.applyFilter(filters(i).kernel, imageLib.buffer2, imageLib.buffer1, rows(j)) + + barrier(i).countDown() + barrier(i).awaitZero() + + if filters.size % 2 == 0 then imageLib.buffer1 + else imageLib.buffer2 + diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala new file mode 100644 index 0000000..2718337 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala @@ -0,0 +1,32 @@ +package concpar22final02.instrumentation + +class Dummy + +trait Monitor: + implicit val dummy: Dummy = new Dummy + + def wait()(implicit i: Dummy) = waitDefault() + + def synchronized[T](e: => T)(implicit i: Dummy) = synchronizedDefault(e) + + def notify()(implicit i: Dummy) = notifyDefault() + + def notifyAll()(implicit i: Dummy) = notifyAllDefault() + + private val lock = new AnyRef + + // Can be overriden. + def waitDefault(): Unit = lock.wait() + def synchronizedDefault[T](toExecute: => T): T = lock.synchronized(toExecute) + def notifyDefault(): Unit = lock.notify() + def notifyAllDefault(): Unit = lock.notifyAll() + +trait LockFreeMonitor extends Monitor: + override def waitDefault() = + throw new Exception("Please use lock-free structures and do not use wait()") + override def synchronizedDefault[T](toExecute: => T): T = + throw new Exception("Please use lock-free structures and do not use synchronized()") + override def notifyDefault() = + throw new Exception("Please use lock-free structures and do not use notify()") + override def notifyAllDefault() = + throw new Exception("Please use lock-free structures and do not use notifyAll()") diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala new file mode 100644 index 0000000..fb4a31e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala @@ -0,0 +1,19 @@ +/* Copyright 2009-2015 EPFL, Lausanne */ +package concpar22final02.instrumentation + +import java.lang.management.* + +/** A collection of methods that can be used to collect run-time statistics about Leon programs. + * This is mostly used to test the resources properties of Leon programs + */ +object Stats: + def timed[T](code: => T)(cont: Long => Unit): T = + var t1 = System.currentTimeMillis() + val r = code + cont((System.currentTimeMillis() - t1)) + r + + def withTime[T](code: => T): (T, Long) = + var t1 = System.currentTimeMillis() + val r = code + (r, (System.currentTimeMillis() - t1)) diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala new file mode 100644 index 0000000..95da2fc --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala @@ -0,0 +1,413 @@ +package concpar22final02 + +import scala.concurrent.* +import scala.concurrent.duration.* +import scala.collection.mutable.HashMap +import scala.util.Random +import instrumentation.SchedulableProblem2 + +import instrumentation.TestHelper.* +import instrumentation.TestUtils.* +import scala.collection.mutable.ArrayBuffer + +class Problem2Suite extends munit.FunSuite: + + val imageSize = 5 + val nThreads = 3 + + def rowsForThread(threadNumber: Int): Array[Int] = + val start: Int = (imageSize * threadNumber) / nThreads + val end: Int = (imageSize * (threadNumber + 1)) / nThreads + (start until end).toArray + + test("Should work when barrier is called by a single thread (10pts)") { + testManySchedules( + 1, + sched => + val temp = new Problem2(imageSize, 1, 1) + ( + List(() => temp.barrier(0).countDown()), + results => + if sched.notifyCount == 0 && sched.notifyAllCount == 0 then + val notifyCount = sched.notifyCount + val notifyAllCount = sched.notifyAllCount + (false, s"No notify call $notifyCount $notifyAllCount") + else if temp.barrier(0).count != 0 then + val count = temp.barrier(0).count + (false, s"Barrier count not equal to zero: $count") + else (true, "") + ) + ) + } + + test("Should work when a single thread processes a single filter (10pts)") { + val temp = new Problem2(imageSize, 1, 1) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline(Array(temp.imageLib.Filter.Outline), Array(0, 1, 2, 3, 4)) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(-2, -3, -3, -3, -2), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(6, 0, 0, 0, 6), + ArrayBuffer(9, 0, 0, 0, 9), + ArrayBuffer(22, 15, 15, 15, 22) + ) + ) + } + + test("Should work when a single thread processes a 2 same filters (15pts)") { + val temp = new Problem2(imageSize, 1, 2) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Identity), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + } + + test("Should work when a single thread processes a 2 different filters (15pts)") { + val temp = new Problem2(imageSize, 1, 2) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Outline), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(-2, -3, -3, -3, -2), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(6, 0, 0, 0, 6), + ArrayBuffer(9, 0, 0, 0, 9), + ArrayBuffer(22, 15, 15, 15, 22) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + } + + test("Should work when barrier is called by two threads (25pts)") { + testManySchedules( + 2, + sched => + val temp = new Problem2(imageSize, 2, 1) + ( + List( + () => + temp.barrier(0).countDown() + temp.barrier(0).awaitZero() + , + () => + temp.barrier(0).countDown() + temp.barrier(0).awaitZero() + ), + results => + if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call") + else if sched.waitCount == 0 then (false, s"No wait call") + else if temp.barrier(0).count != 0 then + val count = temp.barrier(0).count + (false, s"Barrier count not equal to zero: $count") + else (true, "") + ) + ) + } + + test("Should work when barrier is called by multiple threads (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new Problem2(imageSize, nThreads, 1) + ( + (for i <- 0 until nThreads yield () => + temp.barrier(0).countDown() + temp.barrier(0).awaitZero() + ).toList, + results => + if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call") + else if sched.waitCount == 0 then (false, s"No wait call") + else if temp.barrier(0).count != 0 then + val count = temp.barrier(0).count + (false, s"Barrier count not equal to zero: $count") + else (true, "") + ) + ) + } + + test("Should work when a single thread processes a multiple same filters (25pts)") { + val temp = new Problem2(imageSize, 1, 3) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array( + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Outline + ), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(-128, -173, -107, -173, -128), + ArrayBuffer(205, -2, 172, -2, 205), + ArrayBuffer(322, -128, 208, -128, 322), + ArrayBuffer(55, -854, -428, -854, 55), + ArrayBuffer(1180, 433, 751, 433, 1180) + ) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(-16, -22, -18, -22, -16), + ArrayBuffer(23, -1, 9, -1, 23), + ArrayBuffer(36, -18, 0, -18, 36), + ArrayBuffer(29, -67, -45, -67, 29), + ArrayBuffer(152, 74, 90, 74, 152) + ) + ) + } + + test("Should work when a single thread processes multiple filters (25pts)") { + val temp = new Problem2(imageSize, 1, 3) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array( + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Sharpen + ), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(-2, -3, -3, -3, -2), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(6, 0, 0, 0, 6), + ArrayBuffer(9, 0, 0, 0, 9), + ArrayBuffer(22, 15, 15, 15, 22) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(-10, -10, -9, -10, -10), + ArrayBuffer(11, 0, 3, 0, 11), + ArrayBuffer(18, -6, 0, -6, 18), + ArrayBuffer(17, -24, -15, -24, 17), + ArrayBuffer(86, 38, 45, 38, 86) + ) + ) + } + + test("Should work when multiple thread processes a single filter (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 1) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline(Array(temp.imageLib.Filter.Outline), rowsForThread(i))).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(5, 3, 3, 3, 5), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(5, 3, 3, 3, 5) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } + + test("Should work when multiple thread processes two filters (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 2) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline( + Array(temp.imageLib.Filter.Outline, temp.imageLib.Filter.Sharpen), + rowsForThread(i) + )).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(19, 7, 9, 7, 19), + ArrayBuffer(7, -6, -3, -6, 7), + ArrayBuffer(9, -3, 0, -3, 9), + ArrayBuffer(7, -6, -3, -6, 7), + ArrayBuffer(19, 7, 9, 7, 19) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(5, 3, 3, 3, 5), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(5, 3, 3, 3, 5) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } + + test("Should work when multiple thread processes multiple same filters (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline( + Array( + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Identity + ), + rowsForThread(i) + )).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } + + test("Should work when multiple thread processes multiple different filters (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline( + Array( + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Sharpen, + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Sharpen + ), + rowsForThread(i) + )).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(-51, -31, -28, -31, -51), + ArrayBuffer(47, 2, 24, 2, 47), + ArrayBuffer(68, -24, 24, -24, 68), + ArrayBuffer(5, -154, -72, -154, 5), + ArrayBuffer(375, 83, 164, 83, 375) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(-10, -10, -9, -10, -10), + ArrayBuffer(11, 0, 3, 0, 11), + ArrayBuffer(18, -6, 0, -6, 18), + ArrayBuffer(17, -24, -15, -24, 17), + ArrayBuffer(86, 38, 45, 38, 86) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala new file mode 100644 index 0000000..645f9cb --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala @@ -0,0 +1,57 @@ +package concpar22final02.instrumentation + +trait MockedMonitor extends Monitor: + def scheduler: Scheduler + + // Can be overriden. + override def waitDefault() = + scheduler.log("wait") + scheduler.waitCount.incrementAndGet() + scheduler updateThreadState Wait(this, scheduler.threadLocks.tail) + override def synchronizedDefault[T](toExecute: => T): T = + scheduler.log("synchronized check") + val prevLocks = scheduler.threadLocks + scheduler updateThreadState Sync( + this, + prevLocks + ) // If this belongs to prevLocks, should just continue. + scheduler.log("synchronized -> enter") + try toExecute + finally + scheduler updateThreadState Running(prevLocks) + scheduler.log("synchronized -> out") + override def notifyDefault() = + scheduler mapOtherStates { state => + state match + case Wait(lockToAquire, locks) if lockToAquire == this => SyncUnique(this, state.locks) + case e => e + } + scheduler.notifyCount.incrementAndGet() + scheduler.log("notify") + override def notifyAllDefault() = + scheduler mapOtherStates { state => + state match + case Wait(lockToAquire, locks) if lockToAquire == this => Sync(this, state.locks) + case SyncUnique(lockToAquire, locks) if lockToAquire == this => Sync(this, state.locks) + case e => e + } + scheduler.notifyAllCount.incrementAndGet() + scheduler.log("notifyAll") + +abstract class ThreadState: + def locks: Seq[AnyRef] +trait CanContinueIfAcquiresLock extends ThreadState: + def lockToAquire: AnyRef +case object Start extends ThreadState: + def locks: Seq[AnyRef] = Seq.empty +case object End extends ThreadState: + def locks: Seq[AnyRef] = Seq.empty +case class Wait(lockToAquire: AnyRef, locks: Seq[AnyRef]) extends ThreadState +case class SyncUnique(lockToAquire: AnyRef, locks: Seq[AnyRef]) + extends ThreadState + with CanContinueIfAcquiresLock +case class Sync(lockToAquire: AnyRef, locks: Seq[AnyRef]) + extends ThreadState + with CanContinueIfAcquiresLock +case class Running(locks: Seq[AnyRef]) extends ThreadState +case class VariableReadWrite(locks: Seq[AnyRef]) extends ThreadState diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala new file mode 100644 index 0000000..a14587b --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala @@ -0,0 +1,20 @@ +package concpar22final02.instrumentation + +import scala.annotation.tailrec +import concpar22final02.* +import scala.collection.mutable.ArrayBuffer + +class SchedulableBarrier(val scheduler: Scheduler, size: Int) + extends Barrier(size) + with MockedMonitor + +class SchedulableProblem2( + val scheduler: Scheduler, + imageSize: Int, + threadCount: Int, + numFilters: Int +) extends Problem2(imageSize, threadCount, numFilters): + self => + + override val barrier = + ArrayBuffer.fill(numFilters)(SchedulableBarrier(scheduler, threadCount)) diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala new file mode 100644 index 0000000..4001ee3 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala @@ -0,0 +1,294 @@ +package concpar22final02.instrumentation + +import java.util.concurrent.* +import scala.concurrent.duration.* +import scala.collection.mutable.* +import Stats.* + +import java.util.concurrent.atomic.AtomicInteger + +sealed abstract class Result +case class RetVal(rets: List[Any]) extends Result +case class Except(msg: String, stackTrace: Array[StackTraceElement]) extends Result +case class Timeout(msg: String) extends Result + +/** A class that maintains schedule and a set of thread ids. The schedules are advanced after an + * operation of a SchedulableBuffer is performed. Note: the real schedule that is executed may + * deviate from the input schedule due to the adjustments that had to be made for locks + */ +class Scheduler(sched: List[Int]): + val maxOps = 500 // a limit on the maximum number of operations the code is allowed to perform + + var waitCount:AtomicInteger = new AtomicInteger(0) + var notifyCount:AtomicInteger = new AtomicInteger(0) + var notifyAllCount:AtomicInteger = new AtomicInteger(0) + + private var schedule = sched + var numThreads = 0 + private val realToFakeThreadId = Map[Long, Int]() + private val opLog = ListBuffer[String]() // a mutable list (used for efficient concat) + private val threadStates = Map[Int, ThreadState]() + + /** Runs a set of operations in parallel as per the schedule. Each operation may consist of many + * primitive operations like reads or writes to shared data structure each of which should be + * executed using the function `exec`. + * @timeout + * in milliseconds + * @return + * true - all threads completed on time, false -some tests timed out. + */ + def runInParallel(timeout: Long, ops: List[() => Any]): Result = + numThreads = ops.length + val threadRes = Array.fill(numThreads) { None: Any } + var exception: Option[(Throwable, Int)] = None + val syncObject = new Object() + var completed = new AtomicInteger(0) + // create threads + val threads = ops.zipWithIndex.map { case (op, i) => + new Thread( + new Runnable(): + def run(): Unit = + val fakeId = i + 1 + setThreadId(fakeId) + try + updateThreadState(Start) + val res = op() + updateThreadState(End) + threadRes(i) = res + // notify the main thread if all threads have completed + if completed.incrementAndGet() == ops.length then + syncObject.synchronized { syncObject.notifyAll() } + catch + case e: Throwable if exception != None => // do nothing here and silently fail + case e: Throwable => + log(s"throw ${e.toString}") + exception = Some((e, fakeId)) + syncObject.synchronized { syncObject.notifyAll() } + // println(s"$fakeId: ${e.toString}") + // Runtime.getRuntime().halt(0) //exit the JVM and all running threads (no other way to kill other threads) + ) + } + // start all threads + threads.foreach(_.start()) + // wait for all threads to complete, or for an exception to be thrown, or for the time out to expire + var remTime = timeout + syncObject.synchronized { + timed { + if completed.get() != ops.length then syncObject.wait(timeout) } { time => + remTime -= time + } + } + if exception.isDefined then + Except( + s"Thread ${exception.get._2} crashed on the following schedule: \n" + opLog.mkString("\n"), + exception.get._1.getStackTrace + ) + else if remTime <= 1 then // timeout ? using 1 instead of zero to allow for some errors + Timeout(opLog.mkString("\n")) + else + // every thing executed normally + RetVal(threadRes.toList) + + // Updates the state of the current thread + def updateThreadState(state: ThreadState): Unit = + val tid = threadId + synchronized { + threadStates(tid) = state + } + state match + case Sync(lockToAquire, locks) => + if locks.indexOf(lockToAquire) < 0 then waitForTurn + else + // Re-aqcuiring the same lock + updateThreadState(Running(lockToAquire +: locks)) + case Start => waitStart() + case End => removeFromSchedule(tid) + case Running(_) => + case _ => waitForTurn // Wait, SyncUnique, VariableReadWrite + + def waitStart(): Unit = + // while (threadStates.size < numThreads) { + // Thread.sleep(1) + // } + synchronized { + if threadStates.size < numThreads then wait() + else notifyAll() + } + + def threadLocks = + synchronized { + threadStates(threadId).locks + } + + def threadState = + synchronized { + threadStates(threadId) + } + + def mapOtherStates(f: ThreadState => ThreadState) = + val exception = threadId + synchronized { + for k <- threadStates.keys if k != exception do threadStates(k) = f(threadStates(k)) + } + + def log(str: String) = + if (realToFakeThreadId contains Thread.currentThread().getId()) then + val space = (" " * ((threadId - 1) * 2)) + val s = space + threadId + ":" + "\n".r.replaceAllIn(str, "\n" + space + " ") + opLog += s + + /** Executes a read or write operation to a global data structure as per the given schedule + * @param msg + * a message corresponding to the operation that will be logged + */ + def exec[T](primop: => T)(msg: => String, postMsg: => Option[T => String] = None): T = + if !(realToFakeThreadId contains Thread.currentThread().getId()) then primop + else + updateThreadState(VariableReadWrite(threadLocks)) + val m = msg + if m != "" then log(m) + if opLog.size > maxOps then + throw new Exception( + s"Total number of reads/writes performed by threads exceed $maxOps. A possible deadlock!" + ) + val res = primop + postMsg match + case Some(m) => log(m(res)) + case None => + res + + private def setThreadId(fakeId: Int) = synchronized { + realToFakeThreadId(Thread.currentThread.getId) = fakeId + } + + def threadId = + try realToFakeThreadId(Thread.currentThread().getId()) + catch + case e: NoSuchElementException => + throw new Exception( + "You are accessing shared variables in the constructor. This is not allowed. The variables are already initialized!" + ) + + private def isTurn(tid: Int) = synchronized { + (!schedule.isEmpty && schedule.head != tid) + } + + def canProceed(): Boolean = + val tid = threadId + canContinue match + case Some((i, state)) if i == tid => + // println(s"$tid: Runs ! Was in state $state") + canContinue = None + state match + case Sync(lockToAquire, locks) => updateThreadState(Running(lockToAquire +: locks)) + case SyncUnique(lockToAquire, locks) => + mapOtherStates { + _ match + case SyncUnique(lockToAquire2, locks2) if lockToAquire2 == lockToAquire => + Wait(lockToAquire2, locks2) + case e => e + } + updateThreadState(Running(lockToAquire +: locks)) + case VariableReadWrite(locks) => updateThreadState(Running(locks)) + true + case Some((i, state)) => + // println(s"$tid: not my turn but $i !") + false + case None => + false + + var threadPreference = + 0 // In the case the schedule is over, which thread should have the preference to execute. + + /** returns true if the thread can continue to execute, and false otherwise */ + def decide(): Option[(Int, ThreadState)] = + if !threadStates.isEmpty + then // The last thread who enters the decision loop takes the decision. + // println(s"$threadId: I'm taking a decision") + if threadStates.values.forall { + case e: Wait => true + case _ => false + } + then + val waiting = threadStates.keys.map(_.toString).mkString(", ") + val s = if threadStates.size > 1 then "s" else "" + val are = if threadStates.size > 1 then "are" else "is" + throw new Exception( + s"Deadlock: Thread$s $waiting $are waiting but all others have ended and cannot notify them." + ) + else + // Threads can be in Wait, Sync, SyncUnique, and VariableReadWrite mode. + // Let's determine which ones can continue. + val notFree = threadStates.collect { case (id, state) => state.locks }.flatten.toSet + val threadsNotBlocked = threadStates.toSeq.filter { + case (id, v: VariableReadWrite) => true + case (id, v: CanContinueIfAcquiresLock) => + !notFree(v.lockToAquire) || (v.locks contains v.lockToAquire) + case _ => false + } + if threadsNotBlocked.isEmpty then + val waiting = threadStates.keys.map(_.toString).mkString(", ") + val s = if threadStates.size > 1 then "s" else "" + val are = if threadStates.size > 1 then "are" else "is" + val whoHasLock = threadStates.toSeq.flatMap { case (id, state) => + state.locks.map(lock => (lock, id)) + }.toMap + val reason = threadStates + .collect { + case (id, state: CanContinueIfAcquiresLock) if !notFree(state.lockToAquire) => + s"Thread $id is waiting on lock ${state.lockToAquire} held by thread ${whoHasLock(state.lockToAquire)}" + } + .mkString("\n") + throw new Exception(s"Deadlock: Thread$s $waiting are interlocked. Indeed:\n$reason") + else if threadsNotBlocked.size == 1 + then // Do not consume the schedule if only one thread can execute. + Some(threadsNotBlocked(0)) + else + val next = + schedule.indexWhere(t => threadsNotBlocked.exists { case (id, state) => id == t }) + if next != -1 then + // println(s"$threadId: schedule is $schedule, next chosen is ${schedule(next)}") + val chosenOne = schedule(next) // TODO: Make schedule a mutable list. + schedule = schedule.take(next) ++ schedule.drop(next + 1) + Some((chosenOne, threadStates(chosenOne))) + else + threadPreference = (threadPreference + 1) % threadsNotBlocked.size + val chosenOne = threadsNotBlocked(threadPreference) // Maybe another strategy + Some(chosenOne) + // threadsNotBlocked.indexOf(threadId) >= 0 + /* + val tnb = threadsNotBlocked.map(_._1).mkString(",") + val s = if (schedule.isEmpty) "empty" else schedule.mkString(",") + val only = if (schedule.isEmpty) "" else " only" + throw new Exception(s"The schedule is $s but$only threads ${tnb} can continue")*/ + else canContinue + + /** This will be called before a schedulable operation begins. This should not use synchronized + */ + var numThreadsWaiting = new AtomicInteger(0) + // var waitingForDecision = Map[Int, Option[Int]]() // Mapping from thread ids to a number indicating who is going to make the choice. + var canContinue: Option[(Int, ThreadState)] = + None // The result of the decision thread Id of the thread authorized to continue. + private def waitForTurn = + synchronized { + if numThreadsWaiting.incrementAndGet() == threadStates.size then + canContinue = decide() + notifyAll() + // waitingForDecision(threadId) = Some(numThreadsWaiting) + // println(s"$threadId Entering waiting with ticket number $numThreadsWaiting/${waitingForDecision.size}") + while !canProceed() do wait() + } + numThreadsWaiting.decrementAndGet() + + /** To be invoked when a thread is about to complete + */ + private def removeFromSchedule(fakeid: Int) = synchronized { + // println(s"$fakeid: I'm taking a decision because I finished") + schedule = schedule.filterNot(_ == fakeid) + threadStates -= fakeid + if numThreadsWaiting.get() == threadStates.size then + canContinue = decide() + notifyAll() + } + + def getOperationLog() = opLog diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala new file mode 100644 index 0000000..c4bcda0 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala @@ -0,0 +1,127 @@ +package concpar22final02.instrumentation + +import scala.util.Random +import scala.collection.mutable.{Map as MutableMap} + +import Stats.* + +object TestHelper: + val noOfSchedules = 10000 // set this to 100k during deployment + val readWritesPerThread = 20 // maximum number of read/writes possible in one thread + val contextSwitchBound = 10 + val testTimeout = 240 // the total time out for a test in seconds + val schedTimeout = 15 // the total time out for execution of a schedule in secs + + // Helpers + /*def testManySchedules(op1: => Any): Unit = testManySchedules(List(() => op1)) + def testManySchedules(op1: => Any, op2: => Any): Unit = testManySchedules(List(() => op1, () => op2)) + def testManySchedules(op1: => Any, op2: => Any, op3: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3)) + def testManySchedules(op1: => Any, op2: => Any, op3: => Any, op4: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3, () => op4))*/ + + def testSequential[T](ops: Scheduler => Any)(assertions: T => (Boolean, String)) = + testManySchedules( + 1, + (sched: Scheduler) => + (List(() => ops(sched)), (res: List[Any]) => assertions(res.head.asInstanceOf[T])) + ) + + /** @numThreads + * number of threads + * @ops + * operations to be executed, one per thread + * @assertion + * as condition that will executed after all threads have completed (without exceptions) the + * arguments are the results of the threads + */ + def testManySchedules( + numThreads: Int, + ops: Scheduler => ( + List[() => Any], // Threads + List[Any] => (Boolean, String) + ) // Assertion + ) = + var timeout = testTimeout * 1000L + val threadIds = (1 to numThreads) + // (1 to scheduleLength).flatMap(_ => threadIds).toList.permutations.take(noOfSchedules).foreach { + val schedules = (new ScheduleGenerator(numThreads)).schedules() + var schedsExplored = 0 + schedules.takeWhile(_ => schedsExplored <= noOfSchedules && timeout > 0).foreach { + // case _ if timeout <= 0 => // break + case schedule => + schedsExplored += 1 + val schedr = new Scheduler(schedule) + // println("Exploring Sched: "+schedule) + val (threadOps, assertion) = ops(schedr) + if threadOps.size != numThreads then + throw new IllegalStateException( + s"Number of threads: $numThreads, do not match operations of threads: $threadOps" + ) + timed { schedr.runInParallel(schedTimeout * 1000, threadOps) } { t => timeout -= t } match + case Timeout(msg) => + throw new java.lang.AssertionError( + "assertion failed\n" + "The schedule took too long to complete. A possible deadlock! \n" + msg + ) + case Except(msg, stkTrace) => + val traceStr = + "Thread Stack trace: \n" + stkTrace.map(" at " + _.toString).mkString("\n") + throw new java.lang.AssertionError("assertion failed\n" + msg + "\n" + traceStr) + case RetVal(threadRes) => + // check the assertion + val (success, custom_msg) = assertion(threadRes) + if !success then + val msg = + "The following schedule resulted in wrong results: \n" + custom_msg + "\n" + schedr + .getOperationLog() + .mkString("\n") + throw new java.lang.AssertionError("Assertion failed: " + msg) + } + if timeout <= 0 then + throw new java.lang.AssertionError( + "Test took too long to complete! Cannot check all schedules as your code is too slow!" + ) + + /** A schedule generator that is based on the context bound + */ + class ScheduleGenerator(numThreads: Int): + val scheduleLength = readWritesPerThread * numThreads + val rands = (1 to scheduleLength).map(i => new Random(0xcafe * i)) // random numbers for choosing a thread at each position + def schedules(): LazyList[List[Int]] = + var contextSwitches = 0 + var contexts = List[Int]() // a stack of thread ids in the order of context-switches + val remainingOps = MutableMap[Int, Int]() + remainingOps ++= (1 to numThreads).map(i => + (i, readWritesPerThread) + ) // num ops remaining in each thread + val liveThreads = (1 to numThreads).toSeq.toBuffer + + /** Updates remainingOps and liveThreads once a thread is chosen for a position in the + * schedule + */ + def updateState(tid: Int): Unit = + val remOps = remainingOps(tid) + if remOps == 0 then liveThreads -= tid + else remainingOps += (tid -> (remOps - 1)) + val schedule = rands.foldLeft(List[Int]()) { + case (acc, r) if contextSwitches < contextSwitchBound => + val tid = liveThreads(r.nextInt(liveThreads.size)) + contexts match + case prev :: tail if prev != tid => // we have a new context switch here + contexts +:= tid + contextSwitches += 1 + case prev :: tail => + case _ => // init case + contexts +:= tid + updateState(tid) + acc :+ tid + case ( + acc, + _ + ) => // here context-bound has been reached so complete the schedule without any more context switches + if !contexts.isEmpty then contexts = contexts.dropWhile(remainingOps(_) == 0) + val tid = contexts match + case top :: tail => top + case _ => liveThreads(0) // here, there has to be threads that have not even started + updateState(tid) + acc :+ tid + } + schedule #:: schedules() diff --git a/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala new file mode 100644 index 0000000..5c76ec9 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala @@ -0,0 +1,14 @@ +package concpar22final02.instrumentation + +import scala.concurrent.* +import scala.concurrent.duration.* +import scala.concurrent.ExecutionContext.Implicits.global + +object TestUtils: + def failsOrTimesOut[T](action: => T): Boolean = + val asyncAction = Future { + action + } + try Await.result(asyncAction, 2000.millisecond) + catch case _: Throwable => return true + return false diff --git a/previous-exams/2022-final-solutions/concpar22final03/.gitignore b/previous-exams/2022-final-solutions/concpar22final03/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final-solutions/concpar22final03/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final03/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final-solutions/concpar22final03/build.sbt b/previous-exams/2022-final-solutions/concpar22final03/build.sbt new file mode 100644 index 0000000..19eefd4 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/build.sbt @@ -0,0 +1,11 @@ +course := "concpar" +assignment := "concpar22final03" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/build.properties b/previous-exams/2022-final-solutions/concpar22final03/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala new file mode 100644 index 0000000..c032714 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Economics.scala @@ -0,0 +1,44 @@ +package concpar22final03 + +import scala.concurrent.Future + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Random + +trait Economics: + + /** A trading card from the game Scala: The Programming. We can own a card, but once don't + * anymore. + */ + final class Card(val name: String) + def isMine(c: Card): Boolean + + /** This function uses the best available database to return the sell value of a card on the + * market. + */ + def valueOf(cardName: String): Int = List(1, cardName.length).max + + /** This method represents an exact amount of money that can be hold, spent, or put in the bank + */ + final class MoneyBag() + def moneyIn(m: MoneyBag): Int + + /** If you sell a card, at some point in the future you will get some money (in a bag). + */ + def sellCard(c: Card): Future[MoneyBag] + + /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate + * amount and waiting for the transaction to take place. You will own the returned card. + */ + def buyCard(money: MoneyBag, name: String): Future[Card] + + /** This simple bank account holds money for you. You can bring a money bag to increase your + * account's balance, or withdraw a money bag of any size not greater than your account's + * balance. + */ + def balance: Int + def withdraw(amount: Int): Future[MoneyBag] + def deposit(bag: MoneyBag): Future[Unit] + + class NotEnoughMoneyException extends Exception("Not enough money provided to buy those cards") \ No newline at end of file diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala new file mode 100644 index 0000000..3b7fa1b --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/src/main/scala/concpar22final03/Problem3.scala @@ -0,0 +1,52 @@ +package concpar22final03 + +import scala.concurrent.Future +import concurrent.ExecutionContext.Implicits.global + +trait Problem3: + val economics: Economics + import economics.* + + /** The objective is to propose a service of deck building. People come to you with some money and + * some cards they want to sell, and you need to return them a complete deck of the cards they + * want. + */ + def orderDeck( + bag: MoneyBag, + cardsToSell: List[Card], + wantedDeck: List[String] + ): Future[List[Card]] = + + Future { + val totalGivenMoney = + cardsToSell.foldLeft(moneyIn(bag))((sum, c) => sum + valueOf(c.name)) + val totalNeededMoney = + wantedDeck.foldLeft(0)((sum, n) => sum + valueOf(n)) + if totalGivenMoney < totalNeededMoney then + throw new NotEnoughMoneyException() + val soldCards: Future[Unit] = + if moneyIn(bag) != 0 then sellListOfCards(cardsToSell).zip(deposit(bag)).map(_ => ()) + else sellListOfCards(cardsToSell).map(_ => ()) + soldCards.flatMap { _ => buyListOfCards(wantedDeck) } + }.flatten + + /** This helper function will sell the provided list of cards and put the money on your personal + * bank account. It returns a Future of Unit, which indicates when all sales are completed. + */ + def sellListOfCards(cardsToSell: List[Card]): Future[Unit] = + val moneyFromSales: List[Future[Unit]] = cardsToSell.map { c => + sellCard(c).flatMap(m => deposit(m).map { _ => }) + } + Future + .sequence(moneyFromSales) + .map(_ => ()) // Future.sequence transforms a List[Future[A]] into a Future[List[A]] + + /** This helper function, given a list of wanted card names and assuming there is enough money in + * the bank account, will buy (in the future) those cards, and return them. + */ + def buyListOfCards(wantedDeck: List[String]): Future[List[Card]] = + + val boughtCards: List[Future[Card]] = wantedDeck.map { name => + withdraw(valueOf(name)).flatMap(mb => buyCard(mb, name)) + } + Future.sequence(boughtCards) diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala new file mode 100644 index 0000000..a8c327e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala @@ -0,0 +1,98 @@ +package concpar22final03 + +import scala.concurrent.Future + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Random + +trait EconomicsTest extends Economics: + val ownedCards: collection.mutable.Set[Card] = collection.mutable.Set[Card]() + def owned(c: Card): Boolean = ownedCards(c) + def isMine(c: Card): Boolean = ownedCards(c) + + override def valueOf(cardName: String): Int = List(1, cardName.length).max + + /** This is a container for an exact amount of money that can be hold, spent, or put in the bank + */ + val moneyInMoneyBag = collection.mutable.Map[MoneyBag, Int]() + def moneyIn(m: MoneyBag): Int = moneyInMoneyBag.getOrElse(m, 0) + + /** If you sell a card, at some point in the future you will get some money (in a bag). + */ + def sellCard(c: Card): Future[MoneyBag] = + Future { + Thread.sleep(sellWaitTime()) + synchronized( + if owned(c) then + ownedCards.remove(c) + getMoneyBag(valueOf(c.name)) + else + throw Exception( + "This card doesn't belong to you or has already been sold, you can't sell it." + ) + ) + } + + /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate + * amount and waiting for the transaction to take place + */ + def buyCard(bag: MoneyBag, name: String): Future[Card] = + Future { + Thread.sleep(buyWaitTime()) + synchronized { + if moneyIn(bag) != valueOf(name) then + throw Exception( + "You didn't provide the exact amount of money necessary to buy this card." + ) + else moneyInMoneyBag.update(bag, 0) + getCard(name) + } + + } + + /** This simple bank account hold money for you. You can bring a money bag to increase your + * account, or withdraw a money bag of any size not greater than your account's balance. + */ + private var balance_ = initialBalance() + def balance: Int = balance_ + def withdraw(amount: Int): Future[MoneyBag] = + Future { + Thread.sleep(withdrawWaitTime()) + synchronized( + if balance_ >= amount then + balance_ -= amount + getMoneyBag(amount) + else + throw new Exception( + "You try to withdraw more money than you have on your account" + ) + ) + } + + def deposit(bag: MoneyBag): Future[Unit] = + Future { + Thread.sleep(depositWaitTime()) + synchronized { + if moneyInMoneyBag(bag) == 0 then throw new Exception("You are depositing en empty bag!") + else + balance_ += moneyIn(bag) + moneyInMoneyBag.update(bag, 0) + } + } + + def sellWaitTime(): Int + def buyWaitTime(): Int + def withdrawWaitTime(): Int + def depositWaitTime(): Int + def initialBalance(): Int + + def getMoneyBag(i: Int) = + val m = MoneyBag() + synchronized(moneyInMoneyBag.update(m, i)) + m + + def getCard(n: String): Card = + val c = Card(n) + synchronized(ownedCards.update(c, true)) + c diff --git a/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala new file mode 100644 index 0000000..a99852a --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala @@ -0,0 +1,201 @@ +package concpar22final03 + +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} +import scala.util.{Try, Success, Failure} +import scala.concurrent.ExecutionContext.Implicits.global + +class Problem3Suite extends munit.FunSuite: + trait Prob3Test extends Problem3: + override val economics: EconomicsTest + class Test1 extends Prob3Test: + override val economics: EconomicsTest = new EconomicsTest: + override def sellWaitTime() = 10 + override def buyWaitTime() = 20 + override def depositWaitTime() = 30 + override def withdrawWaitTime() = 40 + override def initialBalance() = 0 + class Test2 extends Prob3Test: + override val economics: EconomicsTest = new EconomicsTest: + override def sellWaitTime() = 100 + override def buyWaitTime() = 5 + override def depositWaitTime() = 50 + override def withdrawWaitTime() = 5 + override def initialBalance() = 0 + + class Test3 extends Prob3Test: + override val economics: EconomicsTest = new EconomicsTest: + val rgen = new scala.util.Random(666) + override def sellWaitTime() = rgen.nextInt(100) + override def buyWaitTime() = rgen.nextInt(100) + override def depositWaitTime() = rgen.nextInt(100) + override def withdrawWaitTime() = rgen.nextInt(100) + override def initialBalance() = 0 + + class Test4 extends Prob3Test: + var counter = 5 + def next(): Int = + counter = counter + 5 % 119 + counter + override val economics: EconomicsTest = new EconomicsTest: + override def sellWaitTime() = next() + override def buyWaitTime() = next() + override def depositWaitTime() = next() + override def withdrawWaitTime() = next() + override def initialBalance() = next() + + def testCases = List(new Test1, new Test2) + def unevenTestCases = List(new Test3, new Test4) + + def tot(cards: List[String]): Int = + cards.map[Int]((n: String) => n.length).sum + + def testOk( + t: Prob3Test, + money: Int, + sold: List[String], + wanted: List[String] + ): Unit = + import t.* + import economics.* + val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted) + val r = Await.ready(f, 3.seconds).value.get + assert(r.isSuccess) + r match + case Success(d) => + assertEquals(d.map(_.name).sorted, wanted.sorted) + assertEquals(d.length, wanted.length) + assertEquals(isMine(d.head), true) + case Failure(e) => () + + def testFailure( + t: Prob3Test, + money: Int, + sold: List[String], + wanted: List[String] + ): Unit = + import t.* + import economics.* + val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted) + val r = Await.ready(f, 3.seconds).value.get + assert(r.isFailure) + r match + case Failure(e: NotEnoughMoneyException) => () + case _ => fail("Should have thrown a NotEnoughMoneyException exception, but did not") + + // --- Without sold cards --- + + test( + "Should work correctly when a single card is asked with enough money (no card sold) (20pts)" + ) { + testCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri"))) + } + test( + "Should work correctly when a single card is asked with enough money (no card sold, uneven waiting time) (10pts)" + ) { + unevenTestCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri"))) + } + test( + "Should work correctly when multiple cards are asked with enough money (no card sold) (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + testCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + test( + "Should work correctly when multiple cards are asked with enough money (no card sold, uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + test( + "Should work correctly when asked duplicates of cards, with enough money (no card sold) (20pts)" + ) { + val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd") + testCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + test( + "Should work correctly when asked duplicates of cards, with enough money (no card sold, uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd") + unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + + // --- With sold cards --- + + test( + "Should work correctly when a single card is bought and a single of the same price is sold (20pts)" + ) { + testCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri"))) + } + test( + "Should work correctly when a single card is bought and a single of the same price is sold (uneven waiting time) (10pts)" + ) { + unevenTestCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri"))) + } + + test( + "Should work correctly when multiple cards are asked and multiple of matching values are sold (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777") + testCases.foreach(t => testOk(t, 0, sold, cards)) + } + test( + "Should work correctly when multiple cards are asked and multiple of matching values are sold (uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777") + unevenTestCases.foreach(t => testOk(t, 0, sold, cards)) + } + test( + "Should work correctly when multiple cards are asked and multiple of the same total value are sold (20pts)" + ) { + val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777") + assert(tot(sold2) == tot(cards2)) + testCases.foreach(t => testOk(t, 0, sold2, cards2)) + } + test( + "Should work correctly when multiple cards are asked and multiple of the same total value are sold (uneven waiting time) (10pts)" + ) { + val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777") + assert(tot(sold2) == tot(cards2)) + unevenTestCases.foreach(t => testOk(t, 0, sold2, cards2)) + } + + test( + "Should work correctly when given money and sold cards are sufficient for the wanted cards (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("11111", "2", "33", "44", "5555", "666", "777") + val bagMoney = tot(cards) - tot(sold) + testCases.foreach(t => testOk(t, bagMoney, sold, cards)) + } + test( + "Should work correctly when given money and sold cards are sufficient for the wanted cards (uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("11111", "2", "33", "44", "5555", "666", "777") + val bagMoney = tot(cards) - tot(sold) + unevenTestCases.foreach(t => testOk(t, bagMoney, sold, cards)) + } + + // --- Failures --- + + test( + "Should return a failure when too little money is provided (no card sold) (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + testCases.foreach(t => testFailure(t, tot(cards) - 1, Nil, cards)) + testCases.foreach(t => testFailure(t, tot(cards) - 50, Nil, cards)) + } + + test( + "Should return a failure when too little money or sold cards are provided (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("11111", "2", "33", "44", "5555", "666", "777") + val bagMoney = tot(cards) - tot(sold) + testCases.foreach(t => testFailure(t, bagMoney - 2, sold, cards)) + } diff --git a/previous-exams/2022-final-solutions/concpar22final04/.gitignore b/previous-exams/2022-final-solutions/concpar22final04/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final-solutions/concpar22final04/assignment.sbt b/previous-exams/2022-final-solutions/concpar22final04/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final-solutions/concpar22final04/build.sbt b/previous-exams/2022-final-solutions/concpar22final04/build.sbt new file mode 100644 index 0000000..ea5fb5d --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/build.sbt @@ -0,0 +1,23 @@ +course := "concpar" +assignment := "concpar22final04" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test + +val akkaVersion = "2.6.19" +val logbackVersion = "1.2.11" +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % akkaVersion, + "com.typesafe.akka" %% "akka-testkit" % akkaVersion, + // SLF4J backend + // See https://doc.akka.io/docs/akka/current/typed/logging.html#slf4j-backend + "ch.qos.logback" % "logback-classic" % logbackVersion +) +fork := true +javaOptions ++= Seq("-Dakka.loglevel=Error", "-Dakka.actor.debug.receive=on") + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala b/previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala b/previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala b/previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/build.properties b/previous-exams/2022-final-solutions/concpar22final04/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt b/previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt b/previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala b/previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala new file mode 100644 index 0000000..991be1c --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/src/main/scala/concpar22final04/Problem4.scala @@ -0,0 +1,220 @@ +package concpar22final04 + +import akka.actor.* +import akka.testkit.* +import java.util.Date +import akka.event.LoggingReceive +import akka.pattern.* +import akka.util.Timeout +import concurrent.duration.* +import scala.concurrent.Future +import scala.concurrent.ExecutionContext + +given timeout: Timeout = Timeout(200.millis) + +/** Data associated with a song: a unique `id`, a `title` and an `artist`. + */ +case class Song(id: Int, title: String, artist: String) + +/** An activity in a user's activity feed, representing that `userRef` is + * listening to `songId`. + */ +case class Activity(userId: String, userName: String, songId: Int) + +/** Companion object of the `User` class. + */ +object User: + /** Messages that can be sent to User actors. + */ + enum Protocol: + /** Asks for a user name and id. Should be answered by a Response.Info. + */ + case GetInfo + + /** Asks home page data. Should be answered by a Response.HomepageData. + */ + case GetHomepageData + + /** Like song with id `songId`. + */ + case Like(songId: Int) + + /** Unlike song with id `songId`. + */ + case Unlike(songId: Int) + + /** Adds `subscriber` to the list of subscribers. + */ + case Subscribe(subscriber: ActorRef) + + /** Remove `subscriber` from the list of subscribers. + */ + case Unsubscribe(subscriber: ActorRef) + + /** Adds the activity `activity` to the activity feed. This message will be + * sent by the users this user has subscribed to. + */ + case AddActivity(activity: Activity) + + /** Sent when a user starts playing a song with id `songId`. The recipient + * should notify all its subscribers to update their activity feeds by + * sending them `AddActivity(Activity(...))` messages. No answer is + * expected. This message is sent by external actors. + */ + case Play(songId: Int) + + /** Asks for home page text. Should be answered by a Response.HomepageText. + */ + case GetHomepageText + + /** Responses that can be sent back from User actors. + */ + enum Responses: + /** Answer to a Protocol.GetInfo message + */ + case Info(id: String, name: String) + + /** Answer to a Protocol.GetHomepageData message + */ + case HomepageData(songIds: List[Int], activities: List[Activity]) + + /** Answer to a Protocol.GetHomepageText message + */ + case HomepageText(result: String) + +/** The `User` actor, responsible to handle `User.Protocol` messages. + */ +class User(id: String, name: String, songsStore: ActorRef) extends Actor: + import User.* + import User.Protocol.* + import User.Responses.* + import SongsStore.Protocol.* + import SongsStore.Responses.* + + given ExecutionContext = context.system.dispatcher + + /** Liked songs, by reverse date of liking time (the last liked song must + * be the first must be the first element of the list). Elements of this + * list must be unique: a song can only be liked once. Liking a song + * twice should not change the order. + */ + var likedSongs: List[Int] = List() + + /** Users who have subscribed to this users. + */ + var subscribers: Set[ActorRef] = Set() + + /** Activity feed, by reverse date of activity time (the last added + * activity must be the first element of the list). Items in this list + * should be unique by `userRef`. If a new activity with a `userRef` + * already in the list is added, the former should be removed, so that we + * always see the latest activity for each user we have subscribed to. + */ + var activityFeed: List[Activity] = List() + + + /** This actor's behavior. */ + + override def receive: Receive = LoggingReceive { + case GetInfo => + sender() ! Info(id, name) + case GetHomepageData => + sender() ! HomepageData(likedSongs, activityFeed) + case Like(songId) if !likedSongs.contains(songId) => + likedSongs = songId :: likedSongs + case Unlike(songId) => + likedSongs = likedSongs.filter(_ != songId) + case Subscribe(ref: ActorRef) => + subscribers = subscribers + ref + case Unsubscribe(ref: ActorRef) => + subscribers = subscribers - ref + case AddActivity(activity: Activity) => + activityFeed = activity :: activityFeed.filter(_.userId != activity.userId) + case Play(songId) => + subscribers.foreach(_ ! AddActivity(Activity(id, name, songId))) + case GetHomepageText => + val likedSongsFuture: Future[Songs] = + (songsStore ? GetSongs(likedSongs)).mapTo[Songs] + val activitySongsFuture: Future[Songs] = + (songsStore ? GetSongs(activityFeed.map(_.songId))).mapTo[Songs] + val response: Future[HomepageText] = + for + likedSongs <- likedSongsFuture; + activitySongs <- activitySongsFuture + yield HomepageText(f""" + |Howdy ${name}! + | + |Liked Songs: + |${likedSongs.songs + .map(song => f"* ${song.title} by ${song.artist}") + .mkString("\n")} + | + |Activity Feed: + |${activityFeed + .zip(activitySongs.songs) + .map((activity, song) => f"* ${activity.userName} is listening to ${song.title} by ${song.artist}") + .mkString("\n")}""".stripMargin.trim) + response.pipeTo(sender()) + } + +/** Objects containing the messages a songs store should handle. + */ +object SongsStore: + /** Ask information about a list of songs by their ids. + */ + enum Protocol: + case GetSongs(songIds: List[Int]) + + /** List of `Song` corresponding to the list of IDs given to `GetSongs`. + */ + enum Responses: + case Songs(songs: List[Song]) + +/** A mock implementation of a songs store. + */ +class MockSongsStore extends Actor: + import SongsStore.Protocol.* + import SongsStore.Responses.* + import SongsStore.* + + val songsDB = Map( + 1 -> Song(1, "High Hopes", "Pink Floyd"), + 2 -> Song(2, "Sunny", "Boney M."), + 3 -> Song(3, "J'irai où tu iras", "Céline Dion & Jean-Jacques Goldman"), + 4 -> Song(4, "Ce monde est cruel", "Vald"), + 5 -> Song(5, "Strobe", "deadmau5"), + 6 -> Song(6, "Désenchantée", "Mylène Farmer"), + 7 -> Song(7, "Straight Edge", "Minor Threat"), + 8 -> Song(8, "Hold the line", "TOTO"), + 9 -> Song(9, "Anarchy in the UK", "Sex Pistols"), + 10 -> Song(10, "Breakfast in America", "Supertramp") + ) + + override def receive: Receive = LoggingReceive { case GetSongs(songsIds) => + sender() ! Songs(songsIds.map(songsDB)) + } + +///////////////////////// +// DEBUG // +///////////////////////// + +/** Infrastructure to help debugging. In sbt use `run` to execute this code. + * The TestKit is an actor that can send messages and check the messages it receives (or not). + */ +@main def debug() = new TestKit(ActorSystem("DebugSystem")) with ImplicitSender: + import User.* + import User.Protocol.* + import User.Responses.* + import SongsStore.Protocol.* + import SongsStore.Responses.* + + try + val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore") + val anita = system.actorOf(Props(User("100", "Anita", songsStore))) + + anita ! Like(6) + expectNoMessage() // expects no message is received + + anita ! GetHomepageData + expectMsg(HomepageData(List(6), List())) + finally shutdown(system) diff --git a/previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala b/previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala new file mode 100644 index 0000000..f88d129 --- /dev/null +++ b/previous-exams/2022-final-solutions/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala @@ -0,0 +1,361 @@ +package concpar22final04 + +import akka.actor.* +import akka.testkit.* +import akka.pattern.* +import akka.util.Timeout +import concurrent.duration.* +import User.Protocol.* +import User.Responses.* +import SongsStore.Protocol.* +import SongsStore.Responses.* +import scala.util.{Try, Success, Failure} +import com.typesafe.config.ConfigFactory +import java.util.Date +import scala.util.Random + +class Problem4Suite extends munit.FunSuite: +//--- + Random.setSeed(42178263) +/*+++ + Random.setSeed(42) +++*/ + + test("after receiving GetInfo, should answer with Info (20pts)") { + new MyTestKit: + def tests() = + ada ! GetInfo + expectMsg(Info("1", "Ada")) + } + + test("after receiving GetHomepageData, should answer with the correct HomepageData when there is no liked songs and no activity items (30pts)") { + new MyTestKit: + def tests() = + ada ! GetHomepageData + expectMsg(HomepageData(List(), List())) + } + + test("after receiving Like(1), should add 1 to the list of liked songs (20pts)") { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(1), List())) + } + + test( + "after receiving Like(1) and then Like(2), the list of liked songs should start with List(2, 1) (20pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(2, 1), List())) + } + + test( + "after receiving Like(1) and then Like(1), song 1 should be in the list of liked songs only once (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(1), List())) + } + + test( + "after receiving Like(1), Like(2) and then Like(1), the list of liked songs should start with List(2, 1) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(2, 1), List())) + } + + test( + "after receiving Like(1), Unlike(1) and then Unlike(1), the list of liked songs should not contain song 1 (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(2, 1), List())) + } + + test( + "after receiving Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (20pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + } + + test( + "after receiving Subscribe(aUser), Subscribe(bUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + val donald = new TestProbe(system) + ada ! Subscribe(donald.ref) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + donald.expectMsg(AddActivity(Activity("1", "Ada", 5))) + } + + test( + "after receiving Subscribe(aUser), Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser only once (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + ada ! Subscribe(self) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + expectNoMessage() + } + + test( + "after receiving Subscribe(aUser), Unsubscribe(aUser) and then Play(5), should not send AddActivity(Activity(\"1\", 5)) to aUser (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + ada ! Unsubscribe(self) + expectNoMessage() + ada ! Play(5) + expectNoMessage() + } + + test( + "after receiving AddActivity(Activity(\"1\", 5)), Activity(\"1\", 5) should be in the activity feed (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! AddActivity(Activity("0", "Self", 5)) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(), List(Activity("0", "Self", 5)))) + } + + test( + "after receiving AddActivity(Activity(\"1\", 5)) and AddActivity(Activity(\"1\", 6)), Activity(\"1\", 6) should be in the activity feed and Activity(\"1\", 5) should not (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! AddActivity(Activity("0", "Self", 5)) + expectNoMessage() + ada ! AddActivity(Activity("0", "Self", 6)) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(), List(Activity("0", "Self", 6)))) + } + + test( + "after receiving GetHomepageText, should answer with a result containing \"Howdy $name!\" where $name is the user's name (10pts)" + ) { + new MyTestKit: + def tests() = + val name = Random.alphanumeric.take(5).mkString + val randomUser = system.actorOf(Props(classOf[User], "5", name, songsStore), "user-5") + randomUser ! GetHomepageText + expectMsgClass(classOf[HomepageText]).result.contains(f"Howdy $name!") + } + + test( + "after receiving GetHomepageText, should answer with the correct names of liked songs (1) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(8) + expectNoMessage() + ada ! Like(3) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(2) + .take(4) + .mkString("\n") + .trim, + """ + |Liked Songs: + |* Sunny by Boney M. + |* J'irai où tu iras by Céline Dion & Jean-Jacques Goldman + |* Hold the line by TOTO + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct names of liked songs (2) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(9) + expectNoMessage() + ada ! Like(7) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(2) + .take(3) + .mkString("\n") + .trim, + """ + |Liked Songs: + |* Straight Edge by Minor Threat + |* Anarchy in the UK by Sex Pistols + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct activity feed (1) (10pts)" + ) { + new MyTestKit: + def tests() = + bob ! Subscribe(ada) + expectNoMessage() + carol ! Subscribe(ada) + expectNoMessage() + donald ! Subscribe(ada) + expectNoMessage() + bob ! Play(3) + expectNoMessage() + carol ! Play(8) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(4) + .take(10) + .mkString("\n") + .trim, + """ + |Activity Feed: + |* Carol is listening to Hold the line by TOTO + |* Bob is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct activity feed (2) (10pts)" + ) { + new MyTestKit: + def tests() = + bob ! Subscribe(ada) + expectNoMessage() + carol ! Subscribe(ada) + expectNoMessage() + donald ! Subscribe(ada) + expectNoMessage() + bob ! Play(9) + expectNoMessage() + carol ! Play(10) + expectNoMessage() + donald ! Play(6) + expectNoMessage() + bob ! Play(7) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(4) + .take(10) + .mkString("\n") + .trim, + """ + |Activity Feed: + |* Bob is listening to Straight Edge by Minor Threat + |* Donald is listening to Désenchantée by Mylène Farmer + |* Carol is listening to Breakfast in America by Supertramp + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct text (full test) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + bob ! Subscribe(ada) + expectNoMessage() + carol ! Subscribe(ada) + expectNoMessage() + donald ! Subscribe(ada) + expectNoMessage() + donald ! Play(3) + expectNoMessage() + bob ! Play(4) + expectNoMessage() + carol ! Play(5) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .mkString("\n") + .trim, + """ + |Howdy Ada! + | + |Liked Songs: + |* Sunny by Boney M. + |* High Hopes by Pink Floyd + | + |Activity Feed: + |* Carol is listening to Strobe by deadmau5 + |* Bob is listening to Ce monde est cruel by Vald + |* Donald is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman + """.stripMargin.trim + ) + } + + abstract class MyTestKit + extends TestKit(ActorSystem("TestSystem")) + with ImplicitSender: + val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore") + def makeAda() = system.actorOf(Props(classOf[User], "1", "Ada", songsStore), "user-1") + val ada = makeAda() + val bob = system.actorOf(Props(classOf[User], "2", "Bob", songsStore), "user-2") + val carol = system.actorOf(Props(classOf[User], "3", "Carol", songsStore), "user-3") + val donald = system.actorOf(Props(classOf[User], "4", "Donald", songsStore), "user-4") + def tests(): Unit + try tests() + finally shutdown(system) diff --git a/previous-exams/2022-final/concpar22final01.md b/previous-exams/2022-final/concpar22final01.md new file mode 100644 index 0000000..7b8d10e --- /dev/null +++ b/previous-exams/2022-final/concpar22final01.md @@ -0,0 +1,102 @@ +# Problem 1: Combiners + +## Setup + +Use the following commands to make a fresh clone of your repository: + +``` +git clone -b concpar22final01 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final01 +``` + +If you have issues with the IDE, try [reimporting the +build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting), +if you still have problems, use `compile` in sbt instead. + +## Exercise + +In this exercise, you will implement an array Combiner. The Combiner internally uses a double linked list whose nodes also point to their successor's successor and their predecessor's predecessor. Your goal is to complete the implementation of the (simplified) Combiner interface, by implementing the `result` method to compute the result array from this array combiner. + +Here you can see the declaration of the `DLLCombiner` class and the related `Node` class definition. Look at the `Lib` trait in the `lib.scala` file to find all definitions of relevant functions and classes. + +```scala + class Node(val value: Int): + protected var next: Node = null // null for last node. + protected var next2: Node = null // null for last node. + protected var previous: Node = null // null for first node. + protected var previous2: Node = null // null for first node. + + def getNext: Node = next // do NOT use in the result method + def getNext2: Node = next2 + def getPrevious: Node = previous // do NOT use in the result method + def getPrevious2: Node = previous2 + + def setNext(n: Node): Unit = next = n + def setNext2(n: Node): Unit = next2 = n + def setPrevious(n: Node): Unit = previous = n + def setPrevious2(n: Node): Unit = previous2 = n + + + // Simplified Combiner interface + // Implements methods `+=` and `combine` + // Abstract methods should be implemented in subclasses + abstract class DLLCombiner +``` + +`DLLCombiner` class contains the implementation of methods `+=` and `combine`. You should look at them to better understand the structure of this array Combiner, before moving on to solving this exercise. + +Your task in the exercise will be to implement the `result` method of the `DLLCombinerImplementation` class. This method should compute the result array from this array combiner. In your solution, you should **not** use methods `getNext` and `getPrevious`, but only `getNext2` and `getPrevious2`, to reduce the number of moving operations. + +According to the Combiner contract, `result` should work in parallel. Implement this method efficiently using 4 parallel tasks, by copying the double linked list to the array from both ends at the same time. Two threads should start from the start of the list and two from the end. In each case, one thread would be responsible for odd indexes and the other for even ones. + +Following the description above, your task in the exercise is to: + + 1. Implement the four tasks to copy parts of the resulting array. Each task is responsible for copying one quarter of the array: + - `task1` copies every other Integer element of data array, starting from the first (index 0), up to the middle + - `task2` copies every other Integer element of data array, starting from the second, up to the middle + - `task3` copies every other Integer element of data array, starting from the second to last, up to the middle + - `task4` copies every other Integer element of data array, starting from the last, up to the middle + 2. Implement the method `result` to compute the result array in parallel using those four tasks. + + Here is one example of the `result` method: + +```scala + val combiner1 = new DLLCombinerImplementation + combiner1 += 7 + combiner1 += 2 + combiner1 += 4 + combiner1 += 3 + combiner1 += 9 + combiner1 += 5 + combiner1 += 1 + + val res1 = combiner1.result() // (7, 2, 4, 3, 9, 5, 1) +``` +In this example, `task1` was responsible for copying elements at indexes 0 and 2, `task2` for copying the element at index 1, `task3` for copying elements at indexes 5 and 3, and `task4` for copying element at indexes 6 and 4. + +Here is another example with combining: + +```scala + val c1 = new DLLCombinerImplementation + c1 += 7 + c1 += 2 + c1 += 4 + c1 += 3 + c1 += 9 + c1 += 5 + c1 += 1 + + val c2 = new DLLCombinerImplementation + c2 += 6 + c2 += 8 + c2 += 5 + c2 += 1 + + val c3 = new DLLCombinerImplementation + c3 += 1 + + c1.combine(c2).combine(c3) + val res = c1.result() // (7, 2, 4, 3, 9, 5, 1, 6, 8, 5, 1, 1) +``` + +You can get partial points for solving parts of this exercise. +In your solution you should only make changes to the `DLLCombinerImplementation` class. You are not allowed to change the file `lib.scala`. diff --git a/previous-exams/2022-final/concpar22final01/.gitignore b/previous-exams/2022-final/concpar22final01/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final/concpar22final01/assignment.sbt b/previous-exams/2022-final/concpar22final01/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final/concpar22final01/build.sbt b/previous-exams/2022-final/concpar22final01/build.sbt new file mode 100644 index 0000000..beb0e5c --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/build.sbt @@ -0,0 +1,11 @@ +course := "concpar" +assignment := "concpar22final01" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final/concpar22final01/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final01/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final/concpar22final01/project/build.properties b/previous-exams/2022-final/concpar22final01/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final/concpar22final01/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final01/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final/concpar22final01/project/plugins.sbt b/previous-exams/2022-final/concpar22final01/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala new file mode 100644 index 0000000..70e569c --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/Problem1.scala @@ -0,0 +1,31 @@ +package concpar22final01 + +trait Problem1 extends Lib: + + class DLLCombinerImplementation extends DLLCombiner: + + // Copies every other Integer element of data array, starting from the first (index 0), up to the middle + def task1(data: Array[Int]) = task { + ??? + } + + // Copies every other Integer element of data array, starting from the second, up to the middle + def task2(data: Array[Int]) = task { + ??? + } + + // Copies every other Integer element of data array, starting from the second to last, up to the middle + def task3(data: Array[Int]) = task { + ??? + } + + // Copies every other Integer element of data array, starting from the last, up to the middle + // This is executed on the current thread. + def task4(data: Array[Int]) = + ??? + + def result(): Array[Int] = + val data = new Array[Int](size) + ??? + + data diff --git a/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala new file mode 100644 index 0000000..6d9d6ee --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/src/main/scala/concpar22final01/lib.scala @@ -0,0 +1,81 @@ +package concpar22final01 + +import java.util.concurrent.* +import scala.util.DynamicVariable + +trait Lib: + class Node(val value: Int): + protected var next: Node = null // null for last node. + protected var next2: Node = null // null for last node. + protected var previous: Node = null // null for first node. + protected var previous2: Node = null // null for first node. + + def getNext: Node = next // do NOT use in the result method + def getNext2: Node = next2 + def getPrevious: Node = previous // do NOT use in the result method + def getPrevious2: Node = previous2 + + def setNext(n: Node): Unit = next = n + def setNext2(n: Node): Unit = next2 = n + def setPrevious(n: Node): Unit = previous = n + def setPrevious2(n: Node): Unit = previous2 = n + + // Simplified Combiner interface + // Implements methods += and combine + // Abstract methods should be implemented in subclasses + abstract class DLLCombiner: + var first: Node = null // null for empty lists. + var last: Node = null // null for empty lists. + + var second: Node = null // null for empty lists. + var secondToLast: Node = null // null for empty lists. + + var size: Int = 0 + + // Adds an Integer to this array combiner. + def +=(elem: Int): Unit = + val node = new Node(elem) + if size == 0 then + first = node + last = node + size = 1 + else + last.setNext(node) + node.setPrevious(last) + node.setPrevious2(last.getPrevious) + if size > 1 then last.getPrevious.setNext2(node) + else second = node + secondToLast = last + last = node + size += 1 + + // Combines this array combiner and another given combiner in constant O(1) complexity. + def combine(that: DLLCombiner): DLLCombiner = + if this.size == 0 then that + else if that.size == 0 then this + else + this.last.setNext(that.first) + this.last.setNext2(that.first.getNext) + if this.last.getPrevious != null then + this.last.getPrevious.setNext2(that.first) // important + + that.first.setPrevious(this.last) + that.first.setPrevious2(this.last.getPrevious) + if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important + + if this.size == 1 then second = that.first + + this.size = this.size + that.size + this.last = that.last + this.secondToLast = that.secondToLast + + this + + def task1(data: Array[Int]): ForkJoinTask[Unit] + def task2(data: Array[Int]): ForkJoinTask[Unit] + def task3(data: Array[Int]): ForkJoinTask[Unit] + def task4(data: Array[Int]): Unit + + def result(): Array[Int] + + def task[T](body: => T): ForkJoinTask[T] diff --git a/previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala b/previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala new file mode 100644 index 0000000..a650072 --- /dev/null +++ b/previous-exams/2022-final/concpar22final01/src/test/scala/concpar22final01/Problem1Suite.scala @@ -0,0 +1,491 @@ +package concpar22final01 + +import java.util.concurrent.* +import scala.util.DynamicVariable + +class Problem1Suite extends AbstractProblem1Suite: + + test("[Public] fetch simple result without combining (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 7 + combiner1 += 2 + combiner1 += 3 + combiner1 += 8 + combiner1 += 1 + combiner1 += 2 + combiner1 += 3 + combiner1 += 8 + + val result = combiner1.result() + val array = Array(7, 2, 3, 8, 1, 2, 3, 8) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + test("[Public] fetch result without combining (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 7 + combiner1 += 2 + combiner1 += 3 + combiner1 += 8 + combiner1 += 1 + + val result = combiner1.result() + val array = Array(7, 2, 3, 8, 1) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + test("[Public] fetch result after simple combining (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 7 + combiner1 += 2 + + val combiner2 = new DLLCombinerTest + combiner2 += 3 + combiner2 += 8 + + val combiner3 = new DLLCombinerTest + combiner3 += 1 + combiner3 += 9 + + val combiner4 = new DLLCombinerTest + combiner4 += 3 + combiner4 += 2 + + val result = combiner1.combine(combiner2).combine(combiner3).combine(combiner4).result() + val array = Array(7, 2, 3, 8, 1, 9, 3, 2) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + test("[Public] fetch result - small combiner (2pts)") { + val combiner1 = new DLLCombinerTest + combiner1 += 4 + combiner1 += 2 + combiner1 += 6 + + val result = combiner1.result() + val array = Array(4, 2, 6) + + assert(Range(0, array.size).forall(i => array(i) == result(i))) + } + + + // (25+) 15 / 250 points for correct implementation, don't check parallelism + test("[Correctness] fetch result - simple combiners (2pts)") { + assertCorrectnessSimple() + } + + test("[Correctness] fetch result - small combiners (3pts)") { + assertCorrectnessBasic() + } + + test("[Correctness] fetch result - small combiners after combining (5pts)") { + assertCorrectnessCombined() + } + + test("[Correctness] fetch result - large combiners (5pts)") { + assertCorrectnessLarge() + } + + def assertCorrectnessSimple() = simpleCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + def assertCorrectnessBasic() = basicCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + def assertCorrectnessCombined() = + combinedCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + def assertCorrectnessLarge() = largeCombiners.foreach(elem => assert(compare(elem._1, elem._2))) + + // (25+15+) 25 / 250 points for correct parallel implementation, don't check if it's exactly 1/4 of the array per task + private var count = 0 + private val expected = 3 + + override def task[T](body: => T): ForkJoinTask[T] = + count += 1 + scheduler.value.schedule(body) + + test("[TaskCount] number of newly created tasks should be 3 (5pts)") { + assertTaskCountSimple() + } + + test("[TaskCount] fetch result and check parallel - simple combiners (5pts)") { + assertTaskCountSimple() + assertCorrectnessSimple() + } + + test("[TaskCount] fetch result and check parallel - small combiners (5pts)") { + assertTaskCountSimple() + assertCorrectnessBasic() + } + + test("[TaskCount] fetch result and check parallel - small combiners after combining (5pts)") { + assertTaskCountSimple() + assertCorrectnessCombined() + } + + test("[TaskCount] fetch result and check parallel - large combiners (5pts)") { + assertTaskCountSimple() + assertCorrectnessLarge() + } + + def assertTaskCountSimple(): Unit = + simpleCombiners.foreach(elem => assertTaskCount(elem._1, elem._2)) + + def assertTaskCount(combiner: DLLCombinerTest, array: Array[Int]): Unit = + try + count = 0 + build(combiner, array) + combiner.result() + assertEquals( + count, + expected, { + s"ERROR: Expected $expected instead of $count calls to `task(...)`" + } + ) + finally count = 0 + + // (25+15+25+) 50 / 250 points for correct implementation that uses only next2 and previous2, and not next and previous + test("[Skip2] fetch parallel result and check skip2 - simple combiners (10pts)") { + assertTaskCountSimple() + assertSkipSimple() + assertCorrectnessSimple() + } + + test("[Skip2] fetch result and check skip2 - simple combiners (10pts)") { + assertSkipSimple() + assertCorrectnessSimple() + } + + test("[Skip2] fetch result and check skip2 - small combiners (10pts)") { + assertSkipSimple() + assertCorrectnessBasic() + } + + test("[Skip2] fetch result and check skip2 - small combiners after combining (10pts)") { + assertSkipSimple() + assertCorrectnessCombined() + } + + test("[Skip2] fetch result and check skip2 - large combiners (10pts)") { + assertSkipSimple() + assertCorrectnessLarge() + } + + def assertSkipSimple(): Unit = simpleCombiners.foreach(elem => assertSkip(elem._1, elem._2)) + + def assertSkip(combiner: DLLCombinerTest, array: Array[Int]): Unit = + build(combiner, array) + combiner.result() + assertEquals( + combiner.nonSkipped, + false, { + s"ERROR: Calls to 'next' and 'previous' are not allowed! You should only use 'next2` and 'previous2' in your solution." + } + ) + + // (25+15+25+50+) 75 / 250 points for correct parallel implementation, exactly 1/4 of the array per task + test("[TaskFairness] each task should compute 1/4 of the result (15pts)") { + assertTaskFairness(simpleCombiners.unzip._1) + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - simple combiners (15pts)" + ) { + assertTaskFairness(simpleCombiners.unzip._1) + assertCorrectnessSimple() + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners (15pts)" + ) { + assertTaskFairness(basicCombiners.unzip._1) + assertCorrectnessBasic() + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - small combiners after combining (15pts)" + ) { + assertTaskFairness(combinedCombiners.unzip._1) + assertCorrectnessCombined() + } + + test( + "[TaskFairness] each task should correctly compute 1/4 of the result - large combiners (15pts)" + ) { + assertTaskFairness(largeCombiners.unzip._1) + assertCorrectnessLarge() + } + + def assertTaskFairness(combiners: List[DLLCombiner]): Unit = + def assertNewTaskFairness(combiner: DLLCombiner, task: ForkJoinTask[Unit], data: Array[Int]) = + var count = 0 + var expected = combiner.size / 4 + task.join + count = data.count(elem => elem != 0) + assert((count - expected).abs <= 1) + + def assertMainTaskFairness(combiner: DLLCombiner, task: Unit, data: Array[Int]) = + var count = 0 + var expected = combiner.size / 4 + count = data.count(elem => elem != 0) + assert((count - expected).abs <= 1) + + combiners.foreach { elem => + var data = Array.fill(elem.size)(0) + assertNewTaskFairness(elem, elem.task1(data), data) + + data = Array.fill(elem.size)(0) + assertNewTaskFairness(elem, elem.task2(data), data) + + data = Array.fill(elem.size)(0) + assertNewTaskFairness(elem, elem.task3(data), data) + + data = Array.fill(elem.size)(0) + assertMainTaskFairness(elem, elem.task4(data), data) + } + + // (25+15+25+50+75+) 60 / 250 points for correct parallel implementation, exactly 1/4 of the array per task, exactly the specified quarter + + test( + "[TaskPrecision] each task should compute specified 1/4 of the result - simple combiners (10pts)" + ) { + assertTaskPrecision(simpleCombiners) + } + + test( + "[TaskPrecision] task1 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision1(simpleCombiners) + } + + test( + "[TaskPrecision] task2 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision2(simpleCombiners) + } + + test( + "[TaskPrecision] task3 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision3(simpleCombiners) + } + + test( + "[TaskPrecision] task4 should compute specified 1/4 of the result - simple combiners (5pts)" + ) { + assertTaskPrecision4(simpleCombiners) + } + + test( + "[TaskPrecision] each task should compute specified 1/4 of the result - other combiners (30pts)" + ) { + assertTaskPrecision(basicCombiners) + assertTaskPrecision(combinedCombiners) + assertTaskPrecision(largeCombiners) + } + + def assertTaskPrecision(combiners: List[(DLLCombiner, Array[Int])]): Unit = + assertTaskPrecision1(combiners) + assertTaskPrecision2(combiners) + assertTaskPrecision3(combiners) + assertTaskPrecision4(combiners) + + def assertTaskPrecision1(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task1 = elem._1.task1(data) + task1.join + Range(0, elem._1.size).foreach(i => + (if i < elem._1.size / 2 - 1 && i % 2 == 0 then ref(i) = elem._2(i)) + ) + assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i))) + } + + def assertTaskPrecision2(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task2 = elem._1.task2(data) + task2.join + Range(0, elem._1.size).foreach(i => + (if i < elem._1.size / 2 - 1 && i % 2 == 1 then ref(i) = elem._2(i)) + ) + assert(Range(0, elem._1.size / 2 - 1).forall(i => data(i) == ref(i))) + } + + def assertTaskPrecision3(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task3 = elem._1.task3(data) + task3.join + Range(0, elem._1.size).foreach(i => + (if i > elem._1.size / 2 + 1 && i % 2 == elem._1.size % 2 then ref(i) = elem._2(i)) + ) + assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i))) + } + + def assertTaskPrecision4(combiners: List[(DLLCombiner, Array[Int])]): Unit = + combiners.foreach { elem => + var data = Array.fill(elem._1.size)(0) + var ref = Array.fill(elem._1.size)(0) + val task4 = elem._1.task4(data) + Range(0, elem._1.size).foreach(i => + (if i > elem._1.size / 2 + 1 && i % 2 != elem._1.size % 2 then ref(i) = elem._2(i)) + ) + assert(Range(elem._1.size / 2 + 2, elem._1.size).forall(i => data(i) == ref(i))) + } + +trait AbstractProblem1Suite extends munit.FunSuite with LibImpl: + + def simpleCombiners = buildSimpleCombiners() + def basicCombiners = buildBasicCombiners() + def combinedCombiners = buildCombinedCombiners() + def largeCombiners = buildLargeCombiners() + + def buildSimpleCombiners() = + val simpleCombiners = List( + (new DLLCombinerTest, Array(4, 2, 6, 1, 5, 4, 3, 5, 6, 3, 4, 5, 6, 3, 4, 5)), + (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 1, 1, 1, 1, 1, 1, 1, 2, 3, 2)), + (new DLLCombinerTest, Array.fill(16)(5)) + ) + simpleCombiners.foreach(elem => build(elem._1, elem._2)) + simpleCombiners + + def buildBasicCombiners() = + val basicCombiners = List( + (new DLLCombinerTest, Array(4, 2, 6)), + (new DLLCombinerTest, Array(4, 1, 6)), + (new DLLCombinerTest, Array(7, 2, 2, 9, 3, 2, 11, 12, 5, 14, 15, 1, 17, 23)), + (new DLLCombinerTest, Array(7, 2, 9, 9, 3, 2, 11, 12, 13, 14, 15, 16, 17, 22)), + (new DLLCombinerTest, Array.fill(16)(7)), + (new DLLCombinerTest, Array.fill(16)(4)), + (new DLLCombinerTest, Array.fill(5)(3)), + (new DLLCombinerTest, Array.fill(5)(7)), + (new DLLCombinerTest, Array.fill(5)(4)) + ) + basicCombiners.foreach(elem => build(elem._1, elem._2)) + basicCombiners + + def buildCombinedCombiners() = + var combinedCombiners = List[(DLLCombiner, Array[Int])]() + + Range(1, 10).foreach { n => + val array = basicCombiners.filter(elem => elem._1.size == n).foldLeft(Array[Int]()) { + (acc, i) => acc ++ i._2 + } + val empty: DLLCombiner = new DLLCombinerTest + val combiner = basicCombiners.filter(elem => elem._1.size == n).map(_._1).foldLeft(empty) { + (acc, c) => acc.combine(c) + } + + combinedCombiners = combinedCombiners :+ (combiner, array) + } + combinedCombiners + + def buildLargeCombiners() = + val largeCombiners = List( + (new DLLCombinerTest, Array.fill(1321)(4) ++ Array.fill(1322)(7)), + (new DLLCombinerTest, Array.fill(1341)(2) ++ Array.fill(1122)(5)), + ( + new DLLCombinerTest, + Array.fill(1321)(4) ++ Array.fill(1322)(7) ++ Array.fill(321)(4) ++ Array.fill(322)(7) + ), + (new DLLCombinerTest, Array.fill(992321)(4) ++ Array.fill(99322)(7)), + (new DLLCombinerTest, Array.fill(953211)(4) ++ Array.fill(999322)(1)) + ) + largeCombiners.foreach(elem => build(elem._1, elem._2)) + largeCombiners + + def build(combiner: DLLCombinerTest, array: Array[Int]): DLLCombinerTest = + array.foreach(elem => combiner += elem) + combiner + + def compare(combiner: DLLCombiner, array: Array[Int]): Boolean = + val result = combiner.result() + Range(0, array.size).forall(i => array(i) == result(i)) + + def buildAndCompare(combiner: DLLCombinerTest, array: Array[Int]): Boolean = + array.foreach(elem => combiner += elem) + val result = combiner.result() + Range(0, array.size).forall(i => array(i) == result(i)) + +trait LibImpl extends Problem1: + + val forkJoinPool = new ForkJoinPool + + abstract class TaskScheduler: + def schedule[T](body: => T): ForkJoinTask[T] + + class DefaultTaskScheduler extends TaskScheduler: + def schedule[T](body: => T): ForkJoinTask[T] = + val t = new RecursiveTask[T]: + def compute = body + Thread.currentThread match + case wt: ForkJoinWorkerThread => + t.fork() + case _ => + forkJoinPool.execute(t) + t + + val scheduler = new DynamicVariable[TaskScheduler](new DefaultTaskScheduler) + + def task[T](body: => T): ForkJoinTask[T] = scheduler.value.schedule(body) + + class NodeTest(val v: Int, val myCombiner: DLLCombinerTest) extends Node(v): + override def getNext: Node = + myCombiner.nonSkipped = true + next + override def getNext2: Node = next2 + override def getPrevious: Node = + myCombiner.nonSkipped = true + previous + override def getPrevious2: Node = previous2 + override def setNext(n: Node): Unit = next = n + override def setNext2(n: Node): Unit = next2 = n + override def setPrevious(n: Node): Unit = previous = n + override def setPrevious2(n: Node): Unit = previous2 = n + + class DLLCombinerTest extends DLLCombinerImplementation: + var nonSkipped = false + override def result(): Array[Int] = + nonSkipped = false + super.result() + override def +=(elem: Int): Unit = + val node = new NodeTest(elem, this) + if size == 0 then + first = node + last = node + size = 1 + else + last.setNext(node) + node.setPrevious(last) + node.setPrevious2(last.getPrevious) + if size > 1 then last.getPrevious.setNext2(node) + else second = node + secondToLast = last + last = node + size += 1 + override def combine(that: DLLCombiner): DLLCombiner = + if this.size == 0 then that + else if that.size == 0 then this + else + this.last.setNext(that.first) + this.last.setNext2(that.first.getNext) + if this.last.getPrevious != null then + this.last.getPrevious.setNext2(that.first) // important + + that.first.setPrevious(this.last) + that.first.setPrevious2(this.last.getPrevious) + if that.first.getNext != null then that.first.getNext.setPrevious2(this.last) // important + + if this.size == 1 then second = that.first + + this.size = this.size + that.size + this.last = that.last + this.secondToLast = that.secondToLast + + this diff --git a/previous-exams/2022-final/concpar22final02.md b/previous-exams/2022-final/concpar22final02.md new file mode 100644 index 0000000..59dc15c --- /dev/null +++ b/previous-exams/2022-final/concpar22final02.md @@ -0,0 +1,38 @@ +# Problem 2: Wait and Notify + +## Setup + +Use the following commands to make a fresh clone of your repository: + +``` +git clone -b concpar22final02 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final02 +``` + +If you have issues with the IDE, try [reimporting the +build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting), +if you still have problems, use `compile` in sbt instead. + +## Problem 2.1: Implement Barrier methods + +Your first task is to implement a _barrier_. A barrier acts as a synchronization point between multiple threads. It is used when you want all threads to finish a task before starting the next one. + +You have to complete the following two methods in the `Barrier` class: +1. `awaitZero` function waits for the count value to be zero. +2. `countDown` function decrements the count by one. It notifies other threads if count is less than or equal to zero. + +The barrier will be implemented using these functions. When the thread finish a task, it will decrement the count by using the `countDown` function. Then, it will call the `awaitZero` function to wait for other threads. + +## Problem 2.2: Use the Barrier to apply filters to an image + +In this part, each thread will apply an array of filters to each row of the image. Your task is to use the `Barrier` to act as a synchronization point while applying the filters. Each thread should wait for the other threads to complete the current filter before applying the next filter. + +`ImageLib.scala` provides an implementation of an image processing library with different filters. Each filter has a kernel which is applied on the image. `ImageLib.scala` implements four different filters. It provides an `applyFilter` method which applies a particular filter's kernel on a particular row on the input `Array` and generates the output in the output `Array`. + +The `ImageLib` class takes the size of an image as input. The image is a square matrix. The class has two buffers `buffer1` and `buffer2`. The initial image will be in `buffer1`. For the filtering task, you will switch between these buffers as input and output. For example, for the first filter `buffer1` will the input buffer and `buffer2` will be the output buffer. For the second filter `buffer2` will the input and `buffer1` will be the output and so on for subsequent filters. + +In `Problem2.scala` file where you will complete the `imagePipeline` function: + +- The `imagePipeline` function gets a array of filters and an array of row numbers. This filter needs to be applied to all the rows present in `row` array. After applying each filter, the thread has to wait for other threads to complete before applying the next filter. You will use barrier in this case. +- The `imagePipeline` function will return the output buffer. Note the output buffer can change between `buffer1` and `buffer2` depending on the number of filters applied. + +You can get partial points for solving parts of this exercise. diff --git a/previous-exams/2022-final/concpar22final02/.gitignore b/previous-exams/2022-final/concpar22final02/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final/concpar22final02/assignment.sbt b/previous-exams/2022-final/concpar22final02/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final/concpar22final02/build.sbt b/previous-exams/2022-final/concpar22final02/build.sbt new file mode 100644 index 0000000..61e5a6e --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/build.sbt @@ -0,0 +1,11 @@ +course := "concpar" +assignment := "concpar22final02" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "0.7.26" % Test + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final/concpar22final02/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final02/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final/concpar22final02/project/build.properties b/previous-exams/2022-final/concpar22final02/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final/concpar22final02/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final02/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final/concpar22final02/project/plugins.sbt b/previous-exams/2022-final/concpar22final02/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala new file mode 100644 index 0000000..decdbc9 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/AbstractBarrier.scala @@ -0,0 +1,11 @@ +package concpar22final02 + +import instrumentation.Monitor + +abstract class AbstractBarrier(val numThreads: Int) extends Monitor: + + var count = numThreads + + def awaitZero(): Unit + + def countDown(): Unit diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala new file mode 100644 index 0000000..cec8762 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Barrier.scala @@ -0,0 +1,7 @@ +package concpar22final02 + +class Barrier(numThreads: Int) extends AbstractBarrier(numThreads): + + def awaitZero():Unit = ??? + + def countDown():Unit = ??? diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala new file mode 100644 index 0000000..0f538ea --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/ImageLib.scala @@ -0,0 +1,47 @@ +package concpar22final02 + +import scala.collection.mutable.ArrayBuffer + +class ImageLib(size: Int): + + val buffer1: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(1) + val buffer2: ArrayBuffer[ArrayBuffer[Int]] = ArrayBuffer.fill(size, size)(0) + + enum Filter(val kernel: Array[Array[Int]]): + case Outline extends Filter(Array(Array(-1, -1, -1), Array(-1, 8, -1), Array(-1, -1, -1))) + case Sharpen extends Filter(Array(Array(0, -1, 0), Array(-1, 5, -1), Array(0, -1, 0))) + case Emboss extends Filter(Array(Array(-2, -1, 0), Array(-1, 1, 1), Array(0, 1, 2))) + case Identity extends Filter(Array(Array(0, 0, 0), Array(0, 1, 0), Array(0, 0, 0))) + + def init(input: ArrayBuffer[ArrayBuffer[Int]]) = + for i <- 0 to size - 1 do + for j <- 0 to size - 1 do + buffer1(i)(j) = input(i)(j) + + def computeConvolution( + kernel: Array[Array[Int]], + input: ArrayBuffer[ArrayBuffer[Int]], + row: Int, + column: Int + ): Int = + + val displacement = Array(-1, 0, 1) + var output = 0 + + for i <- 0 to 2 do + for j <- 0 to 2 do + val newI = row + displacement(i) + val newJ = column + displacement(j) + if newI < 0 || newI >= size || newJ < 0 || newJ >= size then output += 0 + else output += (kernel(i)(j) * input(newI)(newJ)) + + output + + def applyFilter( + kernel: Array[Array[Int]], + input: ArrayBuffer[ArrayBuffer[Int]], + output: ArrayBuffer[ArrayBuffer[Int]], + row: Int + ): Unit = + for i <- 0 to input(row).size - 1 do + output(row)(i) = computeConvolution(kernel, input, row, i) diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala new file mode 100644 index 0000000..b09951b --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/Problem2.scala @@ -0,0 +1,12 @@ +package concpar22final02 + +import java.util.concurrent.atomic.AtomicInteger +import scala.collection.mutable.ArrayBuffer + +class Problem2(imageSize: Int, numThreads: Int, numFilters: Int): + + val barrier: ArrayBuffer[Barrier] = ArrayBuffer.fill(numFilters)(Barrier(numThreads)) + + val imageLib: ImageLib = ImageLib(imageSize) + + def imagePipeline(filters: Array[imageLib.Filter], row: Array[Int]): ArrayBuffer[ArrayBuffer[Int]] = ??? diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala new file mode 100644 index 0000000..2718337 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Monitor.scala @@ -0,0 +1,32 @@ +package concpar22final02.instrumentation + +class Dummy + +trait Monitor: + implicit val dummy: Dummy = new Dummy + + def wait()(implicit i: Dummy) = waitDefault() + + def synchronized[T](e: => T)(implicit i: Dummy) = synchronizedDefault(e) + + def notify()(implicit i: Dummy) = notifyDefault() + + def notifyAll()(implicit i: Dummy) = notifyAllDefault() + + private val lock = new AnyRef + + // Can be overriden. + def waitDefault(): Unit = lock.wait() + def synchronizedDefault[T](toExecute: => T): T = lock.synchronized(toExecute) + def notifyDefault(): Unit = lock.notify() + def notifyAllDefault(): Unit = lock.notifyAll() + +trait LockFreeMonitor extends Monitor: + override def waitDefault() = + throw new Exception("Please use lock-free structures and do not use wait()") + override def synchronizedDefault[T](toExecute: => T): T = + throw new Exception("Please use lock-free structures and do not use synchronized()") + override def notifyDefault() = + throw new Exception("Please use lock-free structures and do not use notify()") + override def notifyAllDefault() = + throw new Exception("Please use lock-free structures and do not use notifyAll()") diff --git a/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala new file mode 100644 index 0000000..fb4a31e --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/main/scala/concpar22final02/instrumentation/Stats.scala @@ -0,0 +1,19 @@ +/* Copyright 2009-2015 EPFL, Lausanne */ +package concpar22final02.instrumentation + +import java.lang.management.* + +/** A collection of methods that can be used to collect run-time statistics about Leon programs. + * This is mostly used to test the resources properties of Leon programs + */ +object Stats: + def timed[T](code: => T)(cont: Long => Unit): T = + var t1 = System.currentTimeMillis() + val r = code + cont((System.currentTimeMillis() - t1)) + r + + def withTime[T](code: => T): (T, Long) = + var t1 = System.currentTimeMillis() + val r = code + (r, (System.currentTimeMillis() - t1)) diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala new file mode 100644 index 0000000..95da2fc --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/Problem2Suite.scala @@ -0,0 +1,413 @@ +package concpar22final02 + +import scala.concurrent.* +import scala.concurrent.duration.* +import scala.collection.mutable.HashMap +import scala.util.Random +import instrumentation.SchedulableProblem2 + +import instrumentation.TestHelper.* +import instrumentation.TestUtils.* +import scala.collection.mutable.ArrayBuffer + +class Problem2Suite extends munit.FunSuite: + + val imageSize = 5 + val nThreads = 3 + + def rowsForThread(threadNumber: Int): Array[Int] = + val start: Int = (imageSize * threadNumber) / nThreads + val end: Int = (imageSize * (threadNumber + 1)) / nThreads + (start until end).toArray + + test("Should work when barrier is called by a single thread (10pts)") { + testManySchedules( + 1, + sched => + val temp = new Problem2(imageSize, 1, 1) + ( + List(() => temp.barrier(0).countDown()), + results => + if sched.notifyCount == 0 && sched.notifyAllCount == 0 then + val notifyCount = sched.notifyCount + val notifyAllCount = sched.notifyAllCount + (false, s"No notify call $notifyCount $notifyAllCount") + else if temp.barrier(0).count != 0 then + val count = temp.barrier(0).count + (false, s"Barrier count not equal to zero: $count") + else (true, "") + ) + ) + } + + test("Should work when a single thread processes a single filter (10pts)") { + val temp = new Problem2(imageSize, 1, 1) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline(Array(temp.imageLib.Filter.Outline), Array(0, 1, 2, 3, 4)) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(-2, -3, -3, -3, -2), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(6, 0, 0, 0, 6), + ArrayBuffer(9, 0, 0, 0, 9), + ArrayBuffer(22, 15, 15, 15, 22) + ) + ) + } + + test("Should work when a single thread processes a 2 same filters (15pts)") { + val temp = new Problem2(imageSize, 1, 2) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Identity), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + } + + test("Should work when a single thread processes a 2 different filters (15pts)") { + val temp = new Problem2(imageSize, 1, 2) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array(temp.imageLib.Filter.Identity, temp.imageLib.Filter.Outline), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(-2, -3, -3, -3, -2), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(6, 0, 0, 0, 6), + ArrayBuffer(9, 0, 0, 0, 9), + ArrayBuffer(22, 15, 15, 15, 22) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + ) + } + + test("Should work when barrier is called by two threads (25pts)") { + testManySchedules( + 2, + sched => + val temp = new Problem2(imageSize, 2, 1) + ( + List( + () => + temp.barrier(0).countDown() + temp.barrier(0).awaitZero() + , + () => + temp.barrier(0).countDown() + temp.barrier(0).awaitZero() + ), + results => + if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call") + else if sched.waitCount == 0 then (false, s"No wait call") + else if temp.barrier(0).count != 0 then + val count = temp.barrier(0).count + (false, s"Barrier count not equal to zero: $count") + else (true, "") + ) + ) + } + + test("Should work when barrier is called by multiple threads (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new Problem2(imageSize, nThreads, 1) + ( + (for i <- 0 until nThreads yield () => + temp.barrier(0).countDown() + temp.barrier(0).awaitZero() + ).toList, + results => + if sched.notifyCount == 0 && sched.notifyAllCount == 0 then (false, s"No notify call") + else if sched.waitCount == 0 then (false, s"No wait call") + else if temp.barrier(0).count != 0 then + val count = temp.barrier(0).count + (false, s"Barrier count not equal to zero: $count") + else (true, "") + ) + ) + } + + test("Should work when a single thread processes a multiple same filters (25pts)") { + val temp = new Problem2(imageSize, 1, 3) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array( + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Outline + ), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(-128, -173, -107, -173, -128), + ArrayBuffer(205, -2, 172, -2, 205), + ArrayBuffer(322, -128, 208, -128, 322), + ArrayBuffer(55, -854, -428, -854, 55), + ArrayBuffer(1180, 433, 751, 433, 1180) + ) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(-16, -22, -18, -22, -16), + ArrayBuffer(23, -1, 9, -1, 23), + ArrayBuffer(36, -18, 0, -18, 36), + ArrayBuffer(29, -67, -45, -67, 29), + ArrayBuffer(152, 74, 90, 74, 152) + ) + ) + } + + test("Should work when a single thread processes multiple filters (25pts)") { + val temp = new Problem2(imageSize, 1, 3) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + temp.imagePipeline( + Array( + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Sharpen + ), + Array(0, 1, 2, 3, 4) + ) + assertEquals( + temp.imageLib.buffer1, + ArrayBuffer( + ArrayBuffer(-2, -3, -3, -3, -2), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(6, 0, 0, 0, 6), + ArrayBuffer(9, 0, 0, 0, 9), + ArrayBuffer(22, 15, 15, 15, 22) + ) + ) + assertEquals( + temp.imageLib.buffer2, + ArrayBuffer( + ArrayBuffer(-10, -10, -9, -10, -10), + ArrayBuffer(11, 0, 3, 0, 11), + ArrayBuffer(18, -6, 0, -6, 18), + ArrayBuffer(17, -24, -15, -24, 17), + ArrayBuffer(86, 38, 45, 38, 86) + ) + ) + } + + test("Should work when multiple thread processes a single filter (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 1) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline(Array(temp.imageLib.Filter.Outline), rowsForThread(i))).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(1, 1, 1, 1, 1) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(5, 3, 3, 3, 5), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(5, 3, 3, 3, 5) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } + + test("Should work when multiple thread processes two filters (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 2) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline( + Array(temp.imageLib.Filter.Outline, temp.imageLib.Filter.Sharpen), + rowsForThread(i) + )).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(19, 7, 9, 7, 19), + ArrayBuffer(7, -6, -3, -6, 7), + ArrayBuffer(9, -3, 0, -3, 9), + ArrayBuffer(7, -6, -3, -6, 7), + ArrayBuffer(19, 7, 9, 7, 19) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(5, 3, 3, 3, 5), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(3, 0, 0, 0, 3), + ArrayBuffer(5, 3, 3, 3, 5) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } + + test("Should work when multiple thread processes multiple same filters (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline( + Array( + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Identity + ), + rowsForThread(i) + )).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(0, 0, 0, 0, 0), + ArrayBuffer(1, 1, 1, 1, 1), + ArrayBuffer(2, 2, 2, 2, 2), + ArrayBuffer(3, 3, 3, 3, 3), + ArrayBuffer(4, 4, 4, 4, 4) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } + + test("Should work when multiple thread processes multiple different filters (25pts)") { + testManySchedules( + nThreads, + sched => + val temp = new SchedulableProblem2(sched, imageSize, nThreads, 4) + val buf: ArrayBuffer[ArrayBuffer[Int]] = new ArrayBuffer() + for i: Int <- 0 until imageSize do buf += ArrayBuffer.fill(5)(i) + temp.imageLib.init(buf) + ( + (for i <- 0 until nThreads + yield () => + temp.imagePipeline( + Array( + temp.imageLib.Filter.Outline, + temp.imageLib.Filter.Sharpen, + temp.imageLib.Filter.Identity, + temp.imageLib.Filter.Sharpen + ), + rowsForThread(i) + )).toList, + results => + val expected_buffer1 = ArrayBuffer( + ArrayBuffer(-51, -31, -28, -31, -51), + ArrayBuffer(47, 2, 24, 2, 47), + ArrayBuffer(68, -24, 24, -24, 68), + ArrayBuffer(5, -154, -72, -154, 5), + ArrayBuffer(375, 83, 164, 83, 375) + ) + val expected_buffer2 = ArrayBuffer( + ArrayBuffer(-10, -10, -9, -10, -10), + ArrayBuffer(11, 0, 3, 0, 11), + ArrayBuffer(18, -6, 0, -6, 18), + ArrayBuffer(17, -24, -15, -24, 17), + ArrayBuffer(86, 38, 45, 38, 86) + ) + val res_buffer1 = temp.imageLib.buffer1 + val res_buffer2 = temp.imageLib.buffer2 + if res_buffer1 != expected_buffer1 then + (false, s"Buffer1 expected: $expected_buffer1 , got $res_buffer1") + else if res_buffer2 != expected_buffer2 then + (false, s"Buffer2 expected: $expected_buffer2 , got $res_buffer2") + else (true, "") + ) + ) + } diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala new file mode 100644 index 0000000..645f9cb --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/MockedMonitor.scala @@ -0,0 +1,57 @@ +package concpar22final02.instrumentation + +trait MockedMonitor extends Monitor: + def scheduler: Scheduler + + // Can be overriden. + override def waitDefault() = + scheduler.log("wait") + scheduler.waitCount.incrementAndGet() + scheduler updateThreadState Wait(this, scheduler.threadLocks.tail) + override def synchronizedDefault[T](toExecute: => T): T = + scheduler.log("synchronized check") + val prevLocks = scheduler.threadLocks + scheduler updateThreadState Sync( + this, + prevLocks + ) // If this belongs to prevLocks, should just continue. + scheduler.log("synchronized -> enter") + try toExecute + finally + scheduler updateThreadState Running(prevLocks) + scheduler.log("synchronized -> out") + override def notifyDefault() = + scheduler mapOtherStates { state => + state match + case Wait(lockToAquire, locks) if lockToAquire == this => SyncUnique(this, state.locks) + case e => e + } + scheduler.notifyCount.incrementAndGet() + scheduler.log("notify") + override def notifyAllDefault() = + scheduler mapOtherStates { state => + state match + case Wait(lockToAquire, locks) if lockToAquire == this => Sync(this, state.locks) + case SyncUnique(lockToAquire, locks) if lockToAquire == this => Sync(this, state.locks) + case e => e + } + scheduler.notifyAllCount.incrementAndGet() + scheduler.log("notifyAll") + +abstract class ThreadState: + def locks: Seq[AnyRef] +trait CanContinueIfAcquiresLock extends ThreadState: + def lockToAquire: AnyRef +case object Start extends ThreadState: + def locks: Seq[AnyRef] = Seq.empty +case object End extends ThreadState: + def locks: Seq[AnyRef] = Seq.empty +case class Wait(lockToAquire: AnyRef, locks: Seq[AnyRef]) extends ThreadState +case class SyncUnique(lockToAquire: AnyRef, locks: Seq[AnyRef]) + extends ThreadState + with CanContinueIfAcquiresLock +case class Sync(lockToAquire: AnyRef, locks: Seq[AnyRef]) + extends ThreadState + with CanContinueIfAcquiresLock +case class Running(locks: Seq[AnyRef]) extends ThreadState +case class VariableReadWrite(locks: Seq[AnyRef]) extends ThreadState diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala new file mode 100644 index 0000000..a14587b --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/SchedulableBarrier.scala @@ -0,0 +1,20 @@ +package concpar22final02.instrumentation + +import scala.annotation.tailrec +import concpar22final02.* +import scala.collection.mutable.ArrayBuffer + +class SchedulableBarrier(val scheduler: Scheduler, size: Int) + extends Barrier(size) + with MockedMonitor + +class SchedulableProblem2( + val scheduler: Scheduler, + imageSize: Int, + threadCount: Int, + numFilters: Int +) extends Problem2(imageSize, threadCount, numFilters): + self => + + override val barrier = + ArrayBuffer.fill(numFilters)(SchedulableBarrier(scheduler, threadCount)) diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala new file mode 100644 index 0000000..4001ee3 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/Scheduler.scala @@ -0,0 +1,294 @@ +package concpar22final02.instrumentation + +import java.util.concurrent.* +import scala.concurrent.duration.* +import scala.collection.mutable.* +import Stats.* + +import java.util.concurrent.atomic.AtomicInteger + +sealed abstract class Result +case class RetVal(rets: List[Any]) extends Result +case class Except(msg: String, stackTrace: Array[StackTraceElement]) extends Result +case class Timeout(msg: String) extends Result + +/** A class that maintains schedule and a set of thread ids. The schedules are advanced after an + * operation of a SchedulableBuffer is performed. Note: the real schedule that is executed may + * deviate from the input schedule due to the adjustments that had to be made for locks + */ +class Scheduler(sched: List[Int]): + val maxOps = 500 // a limit on the maximum number of operations the code is allowed to perform + + var waitCount:AtomicInteger = new AtomicInteger(0) + var notifyCount:AtomicInteger = new AtomicInteger(0) + var notifyAllCount:AtomicInteger = new AtomicInteger(0) + + private var schedule = sched + var numThreads = 0 + private val realToFakeThreadId = Map[Long, Int]() + private val opLog = ListBuffer[String]() // a mutable list (used for efficient concat) + private val threadStates = Map[Int, ThreadState]() + + /** Runs a set of operations in parallel as per the schedule. Each operation may consist of many + * primitive operations like reads or writes to shared data structure each of which should be + * executed using the function `exec`. + * @timeout + * in milliseconds + * @return + * true - all threads completed on time, false -some tests timed out. + */ + def runInParallel(timeout: Long, ops: List[() => Any]): Result = + numThreads = ops.length + val threadRes = Array.fill(numThreads) { None: Any } + var exception: Option[(Throwable, Int)] = None + val syncObject = new Object() + var completed = new AtomicInteger(0) + // create threads + val threads = ops.zipWithIndex.map { case (op, i) => + new Thread( + new Runnable(): + def run(): Unit = + val fakeId = i + 1 + setThreadId(fakeId) + try + updateThreadState(Start) + val res = op() + updateThreadState(End) + threadRes(i) = res + // notify the main thread if all threads have completed + if completed.incrementAndGet() == ops.length then + syncObject.synchronized { syncObject.notifyAll() } + catch + case e: Throwable if exception != None => // do nothing here and silently fail + case e: Throwable => + log(s"throw ${e.toString}") + exception = Some((e, fakeId)) + syncObject.synchronized { syncObject.notifyAll() } + // println(s"$fakeId: ${e.toString}") + // Runtime.getRuntime().halt(0) //exit the JVM and all running threads (no other way to kill other threads) + ) + } + // start all threads + threads.foreach(_.start()) + // wait for all threads to complete, or for an exception to be thrown, or for the time out to expire + var remTime = timeout + syncObject.synchronized { + timed { + if completed.get() != ops.length then syncObject.wait(timeout) } { time => + remTime -= time + } + } + if exception.isDefined then + Except( + s"Thread ${exception.get._2} crashed on the following schedule: \n" + opLog.mkString("\n"), + exception.get._1.getStackTrace + ) + else if remTime <= 1 then // timeout ? using 1 instead of zero to allow for some errors + Timeout(opLog.mkString("\n")) + else + // every thing executed normally + RetVal(threadRes.toList) + + // Updates the state of the current thread + def updateThreadState(state: ThreadState): Unit = + val tid = threadId + synchronized { + threadStates(tid) = state + } + state match + case Sync(lockToAquire, locks) => + if locks.indexOf(lockToAquire) < 0 then waitForTurn + else + // Re-aqcuiring the same lock + updateThreadState(Running(lockToAquire +: locks)) + case Start => waitStart() + case End => removeFromSchedule(tid) + case Running(_) => + case _ => waitForTurn // Wait, SyncUnique, VariableReadWrite + + def waitStart(): Unit = + // while (threadStates.size < numThreads) { + // Thread.sleep(1) + // } + synchronized { + if threadStates.size < numThreads then wait() + else notifyAll() + } + + def threadLocks = + synchronized { + threadStates(threadId).locks + } + + def threadState = + synchronized { + threadStates(threadId) + } + + def mapOtherStates(f: ThreadState => ThreadState) = + val exception = threadId + synchronized { + for k <- threadStates.keys if k != exception do threadStates(k) = f(threadStates(k)) + } + + def log(str: String) = + if (realToFakeThreadId contains Thread.currentThread().getId()) then + val space = (" " * ((threadId - 1) * 2)) + val s = space + threadId + ":" + "\n".r.replaceAllIn(str, "\n" + space + " ") + opLog += s + + /** Executes a read or write operation to a global data structure as per the given schedule + * @param msg + * a message corresponding to the operation that will be logged + */ + def exec[T](primop: => T)(msg: => String, postMsg: => Option[T => String] = None): T = + if !(realToFakeThreadId contains Thread.currentThread().getId()) then primop + else + updateThreadState(VariableReadWrite(threadLocks)) + val m = msg + if m != "" then log(m) + if opLog.size > maxOps then + throw new Exception( + s"Total number of reads/writes performed by threads exceed $maxOps. A possible deadlock!" + ) + val res = primop + postMsg match + case Some(m) => log(m(res)) + case None => + res + + private def setThreadId(fakeId: Int) = synchronized { + realToFakeThreadId(Thread.currentThread.getId) = fakeId + } + + def threadId = + try realToFakeThreadId(Thread.currentThread().getId()) + catch + case e: NoSuchElementException => + throw new Exception( + "You are accessing shared variables in the constructor. This is not allowed. The variables are already initialized!" + ) + + private def isTurn(tid: Int) = synchronized { + (!schedule.isEmpty && schedule.head != tid) + } + + def canProceed(): Boolean = + val tid = threadId + canContinue match + case Some((i, state)) if i == tid => + // println(s"$tid: Runs ! Was in state $state") + canContinue = None + state match + case Sync(lockToAquire, locks) => updateThreadState(Running(lockToAquire +: locks)) + case SyncUnique(lockToAquire, locks) => + mapOtherStates { + _ match + case SyncUnique(lockToAquire2, locks2) if lockToAquire2 == lockToAquire => + Wait(lockToAquire2, locks2) + case e => e + } + updateThreadState(Running(lockToAquire +: locks)) + case VariableReadWrite(locks) => updateThreadState(Running(locks)) + true + case Some((i, state)) => + // println(s"$tid: not my turn but $i !") + false + case None => + false + + var threadPreference = + 0 // In the case the schedule is over, which thread should have the preference to execute. + + /** returns true if the thread can continue to execute, and false otherwise */ + def decide(): Option[(Int, ThreadState)] = + if !threadStates.isEmpty + then // The last thread who enters the decision loop takes the decision. + // println(s"$threadId: I'm taking a decision") + if threadStates.values.forall { + case e: Wait => true + case _ => false + } + then + val waiting = threadStates.keys.map(_.toString).mkString(", ") + val s = if threadStates.size > 1 then "s" else "" + val are = if threadStates.size > 1 then "are" else "is" + throw new Exception( + s"Deadlock: Thread$s $waiting $are waiting but all others have ended and cannot notify them." + ) + else + // Threads can be in Wait, Sync, SyncUnique, and VariableReadWrite mode. + // Let's determine which ones can continue. + val notFree = threadStates.collect { case (id, state) => state.locks }.flatten.toSet + val threadsNotBlocked = threadStates.toSeq.filter { + case (id, v: VariableReadWrite) => true + case (id, v: CanContinueIfAcquiresLock) => + !notFree(v.lockToAquire) || (v.locks contains v.lockToAquire) + case _ => false + } + if threadsNotBlocked.isEmpty then + val waiting = threadStates.keys.map(_.toString).mkString(", ") + val s = if threadStates.size > 1 then "s" else "" + val are = if threadStates.size > 1 then "are" else "is" + val whoHasLock = threadStates.toSeq.flatMap { case (id, state) => + state.locks.map(lock => (lock, id)) + }.toMap + val reason = threadStates + .collect { + case (id, state: CanContinueIfAcquiresLock) if !notFree(state.lockToAquire) => + s"Thread $id is waiting on lock ${state.lockToAquire} held by thread ${whoHasLock(state.lockToAquire)}" + } + .mkString("\n") + throw new Exception(s"Deadlock: Thread$s $waiting are interlocked. Indeed:\n$reason") + else if threadsNotBlocked.size == 1 + then // Do not consume the schedule if only one thread can execute. + Some(threadsNotBlocked(0)) + else + val next = + schedule.indexWhere(t => threadsNotBlocked.exists { case (id, state) => id == t }) + if next != -1 then + // println(s"$threadId: schedule is $schedule, next chosen is ${schedule(next)}") + val chosenOne = schedule(next) // TODO: Make schedule a mutable list. + schedule = schedule.take(next) ++ schedule.drop(next + 1) + Some((chosenOne, threadStates(chosenOne))) + else + threadPreference = (threadPreference + 1) % threadsNotBlocked.size + val chosenOne = threadsNotBlocked(threadPreference) // Maybe another strategy + Some(chosenOne) + // threadsNotBlocked.indexOf(threadId) >= 0 + /* + val tnb = threadsNotBlocked.map(_._1).mkString(",") + val s = if (schedule.isEmpty) "empty" else schedule.mkString(",") + val only = if (schedule.isEmpty) "" else " only" + throw new Exception(s"The schedule is $s but$only threads ${tnb} can continue")*/ + else canContinue + + /** This will be called before a schedulable operation begins. This should not use synchronized + */ + var numThreadsWaiting = new AtomicInteger(0) + // var waitingForDecision = Map[Int, Option[Int]]() // Mapping from thread ids to a number indicating who is going to make the choice. + var canContinue: Option[(Int, ThreadState)] = + None // The result of the decision thread Id of the thread authorized to continue. + private def waitForTurn = + synchronized { + if numThreadsWaiting.incrementAndGet() == threadStates.size then + canContinue = decide() + notifyAll() + // waitingForDecision(threadId) = Some(numThreadsWaiting) + // println(s"$threadId Entering waiting with ticket number $numThreadsWaiting/${waitingForDecision.size}") + while !canProceed() do wait() + } + numThreadsWaiting.decrementAndGet() + + /** To be invoked when a thread is about to complete + */ + private def removeFromSchedule(fakeid: Int) = synchronized { + // println(s"$fakeid: I'm taking a decision because I finished") + schedule = schedule.filterNot(_ == fakeid) + threadStates -= fakeid + if numThreadsWaiting.get() == threadStates.size then + canContinue = decide() + notifyAll() + } + + def getOperationLog() = opLog diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala new file mode 100644 index 0000000..c4bcda0 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestHelper.scala @@ -0,0 +1,127 @@ +package concpar22final02.instrumentation + +import scala.util.Random +import scala.collection.mutable.{Map as MutableMap} + +import Stats.* + +object TestHelper: + val noOfSchedules = 10000 // set this to 100k during deployment + val readWritesPerThread = 20 // maximum number of read/writes possible in one thread + val contextSwitchBound = 10 + val testTimeout = 240 // the total time out for a test in seconds + val schedTimeout = 15 // the total time out for execution of a schedule in secs + + // Helpers + /*def testManySchedules(op1: => Any): Unit = testManySchedules(List(() => op1)) + def testManySchedules(op1: => Any, op2: => Any): Unit = testManySchedules(List(() => op1, () => op2)) + def testManySchedules(op1: => Any, op2: => Any, op3: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3)) + def testManySchedules(op1: => Any, op2: => Any, op3: => Any, op4: => Any): Unit = testManySchedules(List(() => op1, () => op2, () => op3, () => op4))*/ + + def testSequential[T](ops: Scheduler => Any)(assertions: T => (Boolean, String)) = + testManySchedules( + 1, + (sched: Scheduler) => + (List(() => ops(sched)), (res: List[Any]) => assertions(res.head.asInstanceOf[T])) + ) + + /** @numThreads + * number of threads + * @ops + * operations to be executed, one per thread + * @assertion + * as condition that will executed after all threads have completed (without exceptions) the + * arguments are the results of the threads + */ + def testManySchedules( + numThreads: Int, + ops: Scheduler => ( + List[() => Any], // Threads + List[Any] => (Boolean, String) + ) // Assertion + ) = + var timeout = testTimeout * 1000L + val threadIds = (1 to numThreads) + // (1 to scheduleLength).flatMap(_ => threadIds).toList.permutations.take(noOfSchedules).foreach { + val schedules = (new ScheduleGenerator(numThreads)).schedules() + var schedsExplored = 0 + schedules.takeWhile(_ => schedsExplored <= noOfSchedules && timeout > 0).foreach { + // case _ if timeout <= 0 => // break + case schedule => + schedsExplored += 1 + val schedr = new Scheduler(schedule) + // println("Exploring Sched: "+schedule) + val (threadOps, assertion) = ops(schedr) + if threadOps.size != numThreads then + throw new IllegalStateException( + s"Number of threads: $numThreads, do not match operations of threads: $threadOps" + ) + timed { schedr.runInParallel(schedTimeout * 1000, threadOps) } { t => timeout -= t } match + case Timeout(msg) => + throw new java.lang.AssertionError( + "assertion failed\n" + "The schedule took too long to complete. A possible deadlock! \n" + msg + ) + case Except(msg, stkTrace) => + val traceStr = + "Thread Stack trace: \n" + stkTrace.map(" at " + _.toString).mkString("\n") + throw new java.lang.AssertionError("assertion failed\n" + msg + "\n" + traceStr) + case RetVal(threadRes) => + // check the assertion + val (success, custom_msg) = assertion(threadRes) + if !success then + val msg = + "The following schedule resulted in wrong results: \n" + custom_msg + "\n" + schedr + .getOperationLog() + .mkString("\n") + throw new java.lang.AssertionError("Assertion failed: " + msg) + } + if timeout <= 0 then + throw new java.lang.AssertionError( + "Test took too long to complete! Cannot check all schedules as your code is too slow!" + ) + + /** A schedule generator that is based on the context bound + */ + class ScheduleGenerator(numThreads: Int): + val scheduleLength = readWritesPerThread * numThreads + val rands = (1 to scheduleLength).map(i => new Random(0xcafe * i)) // random numbers for choosing a thread at each position + def schedules(): LazyList[List[Int]] = + var contextSwitches = 0 + var contexts = List[Int]() // a stack of thread ids in the order of context-switches + val remainingOps = MutableMap[Int, Int]() + remainingOps ++= (1 to numThreads).map(i => + (i, readWritesPerThread) + ) // num ops remaining in each thread + val liveThreads = (1 to numThreads).toSeq.toBuffer + + /** Updates remainingOps and liveThreads once a thread is chosen for a position in the + * schedule + */ + def updateState(tid: Int): Unit = + val remOps = remainingOps(tid) + if remOps == 0 then liveThreads -= tid + else remainingOps += (tid -> (remOps - 1)) + val schedule = rands.foldLeft(List[Int]()) { + case (acc, r) if contextSwitches < contextSwitchBound => + val tid = liveThreads(r.nextInt(liveThreads.size)) + contexts match + case prev :: tail if prev != tid => // we have a new context switch here + contexts +:= tid + contextSwitches += 1 + case prev :: tail => + case _ => // init case + contexts +:= tid + updateState(tid) + acc :+ tid + case ( + acc, + _ + ) => // here context-bound has been reached so complete the schedule without any more context switches + if !contexts.isEmpty then contexts = contexts.dropWhile(remainingOps(_) == 0) + val tid = contexts match + case top :: tail => top + case _ => liveThreads(0) // here, there has to be threads that have not even started + updateState(tid) + acc :+ tid + } + schedule #:: schedules() diff --git a/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala new file mode 100644 index 0000000..5c76ec9 --- /dev/null +++ b/previous-exams/2022-final/concpar22final02/src/test/scala/concpar22final02/instrumentation/TestUtils.scala @@ -0,0 +1,14 @@ +package concpar22final02.instrumentation + +import scala.concurrent.* +import scala.concurrent.duration.* +import scala.concurrent.ExecutionContext.Implicits.global + +object TestUtils: + def failsOrTimesOut[T](action: => T): Boolean = + val asyncAction = Future { + action + } + try Await.result(asyncAction, 2000.millisecond) + catch case _: Throwable => return true + return false diff --git a/previous-exams/2022-final/concpar22final03.md b/previous-exams/2022-final/concpar22final03.md new file mode 100644 index 0000000..84cea5e --- /dev/null +++ b/previous-exams/2022-final/concpar22final03.md @@ -0,0 +1,37 @@ +# Problem 3: Futures + +## Setup + +Use the following commands to make a fresh clone of your repository: + +``` +git clone -b concpar22final03 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final03 +``` + +If you have issues with the IDE, try [reimporting the +build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting), +if you still have problems, use `compile` in sbt instead. + +## Exercise + +The famous trading cards game Scala: The Programming has recently been gaining traction. +In this little exercise, you will propose to clients your services as a trader: Buying and selling cards in groups on demand. + +You are provided in the file `Economics.scala` with an interface to handle asynchronous buying and selling of cards and management of your money. Do not modify this file. More precisely, this interface defines: + +- A `Card`, which has a `name` and which you can own (`isMine == true`) or not (`isMine == false`). This is only to prevent a card from being sold or used multiple times, and you may not need it. You can find the value of a card using `valueOf`. +- A `MoneyBag`, which is a container to transport money. Similarly, the money inside a bag can only be used once. The function `moneyIn` informs you of the bag's value, should you need it. +- The function `sellCard`, which will sell a card through a `Future` and gives you back a `Future[MoneyBag]`. If you do not own the card, the `Future` will fail. +- The function `buyCard`, which will consume a given `MoneyBag` and handle you, through a `Future`, the requested card. The provided bag must contain the exact amount of money corresponding to the card's value. +- Finally, you have a bank account with the following functions: + - `balance`: indicates your current monetary possession + - `withdraw`: substracts a given amount from your balance, and handles you a corresponding `Future[MoneyBag]` + - `deposit`: consumes a moneyBag and returns a `Future[Unit]` when the balance is updated. Note that you should not deposit empty moneyBags! If you do, you will get a failure, possibly indicating that you try to deposit the same bag twice. + +Your task in the exercise is to implement the function `orderDeck` in the file `Problem3.scala`. In a `Future`, start by checking that the sum of the money and the value of the cards the client gives you is large enough to buy the requested list of cards. If it is not, then the future should fail with a `NotEnoughMoneyException`. + +Then, sell all provided cards and put the received moneyBags in your bank accounts by chaining asynchronously the `Futures` of `sellCard` and `deposit`. You will obtain a `List[Future[Unit]]`, which should be converted into a `Future[Unit]` (so that when this `Future` returns, all deposits have finished). Those steps are provided for you in the helper function `sellListOfCards`. + +Then, do the opposite: withdraw `MoneyBags` of adequate value and use them to buy cards. Finally agregate the `List[Future[Card]]` into a `Future[List[Card]]`. You can implement those steps into the `buyListOfCards` function. Take inspiration from the given example `sellListOfCards`, and combine them in the `orderDeck` function. + +Final tip: Make good use of `map`, `flatMap` and `zip` on futures. diff --git a/previous-exams/2022-final/concpar22final03/.gitignore b/previous-exams/2022-final/concpar22final03/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final/concpar22final03/assignment.sbt b/previous-exams/2022-final/concpar22final03/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final/concpar22final03/build.sbt b/previous-exams/2022-final/concpar22final03/build.sbt new file mode 100644 index 0000000..19eefd4 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/build.sbt @@ -0,0 +1,11 @@ +course := "concpar" +assignment := "concpar22final03" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final/concpar22final03/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final03/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final/concpar22final03/project/build.properties b/previous-exams/2022-final/concpar22final03/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final/concpar22final03/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final03/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final/concpar22final03/project/plugins.sbt b/previous-exams/2022-final/concpar22final03/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala new file mode 100644 index 0000000..c032714 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Economics.scala @@ -0,0 +1,44 @@ +package concpar22final03 + +import scala.concurrent.Future + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Random + +trait Economics: + + /** A trading card from the game Scala: The Programming. We can own a card, but once don't + * anymore. + */ + final class Card(val name: String) + def isMine(c: Card): Boolean + + /** This function uses the best available database to return the sell value of a card on the + * market. + */ + def valueOf(cardName: String): Int = List(1, cardName.length).max + + /** This method represents an exact amount of money that can be hold, spent, or put in the bank + */ + final class MoneyBag() + def moneyIn(m: MoneyBag): Int + + /** If you sell a card, at some point in the future you will get some money (in a bag). + */ + def sellCard(c: Card): Future[MoneyBag] + + /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate + * amount and waiting for the transaction to take place. You will own the returned card. + */ + def buyCard(money: MoneyBag, name: String): Future[Card] + + /** This simple bank account holds money for you. You can bring a money bag to increase your + * account's balance, or withdraw a money bag of any size not greater than your account's + * balance. + */ + def balance: Int + def withdraw(amount: Int): Future[MoneyBag] + def deposit(bag: MoneyBag): Future[Unit] + + class NotEnoughMoneyException extends Exception("Not enough money provided to buy those cards") \ No newline at end of file diff --git a/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala new file mode 100644 index 0000000..46c5a92 --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/src/main/scala/concpar22final03/Problem3.scala @@ -0,0 +1,38 @@ +package concpar22final03 + +import scala.concurrent.Future +import concurrent.ExecutionContext.Implicits.global + +trait Problem3: + val economics: Economics + import economics.* + + /** The objective is to propose a service of deck building. People come to you with some money and + * some cards they want to sell, and you need to return them a complete deck of the cards they + * want. + */ + def orderDeck( + bag: MoneyBag, + cardsToSell: List[Card], + wantedDeck: List[String] + ): Future[List[Card]] = + Future { + ??? // : Future[List[Card]] + }.flatten + + /** This helper function will sell the provided list of cards and put the money on your personal + * bank account. It returns a Future of Unit, which indicates when all sales are completed. + */ + def sellListOfCards(cardsToSell: List[Card]): Future[Unit] = + val moneyFromSales: List[Future[Unit]] = cardsToSell.map { c => + sellCard(c).flatMap(m => deposit(m).map { _ => }) + } + Future + .sequence(moneyFromSales) + .map(_ => ()) // Future.sequence transforms a List[Future[A]] into a Future[List[A]] + + /** This helper function, given a list of wanted card names and assuming there is enough money in + * the bank account, will buy (in the future) those cards, and return them. + */ + def buyListOfCards(wantedDeck: List[String]): Future[List[Card]] = + ??? diff --git a/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala new file mode 100644 index 0000000..a8c327e --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/EconomicsTest.scala @@ -0,0 +1,98 @@ +package concpar22final03 + +import scala.concurrent.Future + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.util.Random + +trait EconomicsTest extends Economics: + val ownedCards: collection.mutable.Set[Card] = collection.mutable.Set[Card]() + def owned(c: Card): Boolean = ownedCards(c) + def isMine(c: Card): Boolean = ownedCards(c) + + override def valueOf(cardName: String): Int = List(1, cardName.length).max + + /** This is a container for an exact amount of money that can be hold, spent, or put in the bank + */ + val moneyInMoneyBag = collection.mutable.Map[MoneyBag, Int]() + def moneyIn(m: MoneyBag): Int = moneyInMoneyBag.getOrElse(m, 0) + + /** If you sell a card, at some point in the future you will get some money (in a bag). + */ + def sellCard(c: Card): Future[MoneyBag] = + Future { + Thread.sleep(sellWaitTime()) + synchronized( + if owned(c) then + ownedCards.remove(c) + getMoneyBag(valueOf(c.name)) + else + throw Exception( + "This card doesn't belong to you or has already been sold, you can't sell it." + ) + ) + } + + /** You can buy any "Scala: The Programming" card by providing a bag of money with the appropriate + * amount and waiting for the transaction to take place + */ + def buyCard(bag: MoneyBag, name: String): Future[Card] = + Future { + Thread.sleep(buyWaitTime()) + synchronized { + if moneyIn(bag) != valueOf(name) then + throw Exception( + "You didn't provide the exact amount of money necessary to buy this card." + ) + else moneyInMoneyBag.update(bag, 0) + getCard(name) + } + + } + + /** This simple bank account hold money for you. You can bring a money bag to increase your + * account, or withdraw a money bag of any size not greater than your account's balance. + */ + private var balance_ = initialBalance() + def balance: Int = balance_ + def withdraw(amount: Int): Future[MoneyBag] = + Future { + Thread.sleep(withdrawWaitTime()) + synchronized( + if balance_ >= amount then + balance_ -= amount + getMoneyBag(amount) + else + throw new Exception( + "You try to withdraw more money than you have on your account" + ) + ) + } + + def deposit(bag: MoneyBag): Future[Unit] = + Future { + Thread.sleep(depositWaitTime()) + synchronized { + if moneyInMoneyBag(bag) == 0 then throw new Exception("You are depositing en empty bag!") + else + balance_ += moneyIn(bag) + moneyInMoneyBag.update(bag, 0) + } + } + + def sellWaitTime(): Int + def buyWaitTime(): Int + def withdrawWaitTime(): Int + def depositWaitTime(): Int + def initialBalance(): Int + + def getMoneyBag(i: Int) = + val m = MoneyBag() + synchronized(moneyInMoneyBag.update(m, i)) + m + + def getCard(n: String): Card = + val c = Card(n) + synchronized(ownedCards.update(c, true)) + c diff --git a/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala new file mode 100644 index 0000000..a99852a --- /dev/null +++ b/previous-exams/2022-final/concpar22final03/src/test/scala/concpar22final03/Problem3Suite.scala @@ -0,0 +1,201 @@ +package concpar22final03 + +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} +import scala.util.{Try, Success, Failure} +import scala.concurrent.ExecutionContext.Implicits.global + +class Problem3Suite extends munit.FunSuite: + trait Prob3Test extends Problem3: + override val economics: EconomicsTest + class Test1 extends Prob3Test: + override val economics: EconomicsTest = new EconomicsTest: + override def sellWaitTime() = 10 + override def buyWaitTime() = 20 + override def depositWaitTime() = 30 + override def withdrawWaitTime() = 40 + override def initialBalance() = 0 + class Test2 extends Prob3Test: + override val economics: EconomicsTest = new EconomicsTest: + override def sellWaitTime() = 100 + override def buyWaitTime() = 5 + override def depositWaitTime() = 50 + override def withdrawWaitTime() = 5 + override def initialBalance() = 0 + + class Test3 extends Prob3Test: + override val economics: EconomicsTest = new EconomicsTest: + val rgen = new scala.util.Random(666) + override def sellWaitTime() = rgen.nextInt(100) + override def buyWaitTime() = rgen.nextInt(100) + override def depositWaitTime() = rgen.nextInt(100) + override def withdrawWaitTime() = rgen.nextInt(100) + override def initialBalance() = 0 + + class Test4 extends Prob3Test: + var counter = 5 + def next(): Int = + counter = counter + 5 % 119 + counter + override val economics: EconomicsTest = new EconomicsTest: + override def sellWaitTime() = next() + override def buyWaitTime() = next() + override def depositWaitTime() = next() + override def withdrawWaitTime() = next() + override def initialBalance() = next() + + def testCases = List(new Test1, new Test2) + def unevenTestCases = List(new Test3, new Test4) + + def tot(cards: List[String]): Int = + cards.map[Int]((n: String) => n.length).sum + + def testOk( + t: Prob3Test, + money: Int, + sold: List[String], + wanted: List[String] + ): Unit = + import t.* + import economics.* + val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted) + val r = Await.ready(f, 3.seconds).value.get + assert(r.isSuccess) + r match + case Success(d) => + assertEquals(d.map(_.name).sorted, wanted.sorted) + assertEquals(d.length, wanted.length) + assertEquals(isMine(d.head), true) + case Failure(e) => () + + def testFailure( + t: Prob3Test, + money: Int, + sold: List[String], + wanted: List[String] + ): Unit = + import t.* + import economics.* + val f = orderDeck(getMoneyBag(money), sold.map(getCard), wanted) + val r = Await.ready(f, 3.seconds).value.get + assert(r.isFailure) + r match + case Failure(e: NotEnoughMoneyException) => () + case _ => fail("Should have thrown a NotEnoughMoneyException exception, but did not") + + // --- Without sold cards --- + + test( + "Should work correctly when a single card is asked with enough money (no card sold) (20pts)" + ) { + testCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri"))) + } + test( + "Should work correctly when a single card is asked with enough money (no card sold, uneven waiting time) (10pts)" + ) { + unevenTestCases.foreach(t => testOk(t, 7, Nil, List("Tefeiri"))) + } + test( + "Should work correctly when multiple cards are asked with enough money (no card sold) (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + testCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + test( + "Should work correctly when multiple cards are asked with enough money (no card sold, uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + test( + "Should work correctly when asked duplicates of cards, with enough money (no card sold) (20pts)" + ) { + val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd") + testCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + test( + "Should work correctly when asked duplicates of cards, with enough money (no card sold, uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "aaaa", "aaaa", "dd", "dd", "dd", "dd") + unevenTestCases.foreach(t => testOk(t, tot(cards), Nil, cards)) + } + + // --- With sold cards --- + + test( + "Should work correctly when a single card is bought and a single of the same price is sold (20pts)" + ) { + testCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri"))) + } + test( + "Should work correctly when a single card is bought and a single of the same price is sold (uneven waiting time) (10pts)" + ) { + unevenTestCases.foreach(t => testOk(t, 0, List("Chandra"), List("Tefeiri"))) + } + + test( + "Should work correctly when multiple cards are asked and multiple of matching values are sold (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777") + testCases.foreach(t => testOk(t, 0, sold, cards)) + } + test( + "Should work correctly when multiple cards are asked and multiple of matching values are sold (uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("1111111", "2", "3333", "44", "55555", "666", "7777") + unevenTestCases.foreach(t => testOk(t, 0, sold, cards)) + } + test( + "Should work correctly when multiple cards are asked and multiple of the same total value are sold (20pts)" + ) { + val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777") + assert(tot(sold2) == tot(cards2)) + testCases.foreach(t => testOk(t, 0, sold2, cards2)) + } + test( + "Should work correctly when multiple cards are asked and multiple of the same total value are sold (uneven waiting time) (10pts)" + ) { + val cards2 = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold2 = List("111111111", "22", "3", "44", "555555", "666", "777") + assert(tot(sold2) == tot(cards2)) + unevenTestCases.foreach(t => testOk(t, 0, sold2, cards2)) + } + + test( + "Should work correctly when given money and sold cards are sufficient for the wanted cards (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("11111", "2", "33", "44", "5555", "666", "777") + val bagMoney = tot(cards) - tot(sold) + testCases.foreach(t => testOk(t, bagMoney, sold, cards)) + } + test( + "Should work correctly when given money and sold cards are sufficient for the wanted cards (uneven waiting time) (10pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("11111", "2", "33", "44", "5555", "666", "777") + val bagMoney = tot(cards) - tot(sold) + unevenTestCases.foreach(t => testOk(t, bagMoney, sold, cards)) + } + + // --- Failures --- + + test( + "Should return a failure when too little money is provided (no card sold) (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + testCases.foreach(t => testFailure(t, tot(cards) - 1, Nil, cards)) + testCases.foreach(t => testFailure(t, tot(cards) - 50, Nil, cards)) + } + + test( + "Should return a failure when too little money or sold cards are provided (20pts)" + ) { + val cards = List("aaaa", "bbb", "ccccc", "dd", "eeee", "f", "ggggggg") + val sold = List("11111", "2", "33", "44", "5555", "666", "777") + val bagMoney = tot(cards) - tot(sold) + testCases.foreach(t => testFailure(t, bagMoney - 2, sold, cards)) + } diff --git a/previous-exams/2022-final/concpar22final04.md b/previous-exams/2022-final/concpar22final04.md new file mode 100644 index 0000000..0fd39f5 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04.md @@ -0,0 +1,98 @@ +# Problem 4: Implementing Spotify using actors + +## Setup + +Use the following commands to make a fresh clone of your repository: + +``` +git clone -b concpar22final04 git@gitlab.epfl.ch:lamp/student-repositories-s22/cs206-GASPAR.git concpar22final04 +``` + +If you have issues with the IDE, try [reimporting the +build](https://gitlab.epfl.ch/lamp/cs206/-/blob/master/labs/example-lab.md#troubleshooting), +if you still have problems, use `compile` in sbt instead. + +## Useful Links + +- [Akka Classic documentation](https://doc.akka.io/docs/akka/current/index-classic.html), in particular: + - [Classic Actors](https://doc.akka.io/docs/akka/current/actors.html) + - [Testing Classic Actors](https://doc.akka.io/docs/akka/current/testing.html) + - [Classic Logging](https://doc.akka.io/docs/akka/current/logging.html) +- [Akka Classic API reference](https://doc.akka.io/api/akka/current/akka/actor/index.html) +- [CS206 slides](https://gitlab.epfl.ch/lamp/cs206/-/tree/master/slides) + +## Overview + +In this exercise, you will implement the core functionalities of an online music streaming service. Users will be modelled as individual Akka actors. + +- Each user has a unique identifier and a name. +- Each user can like and unlike songs (stored in the user's _liked songs_ list). Liked songs are sorted by reverse date of liking time (the last liked song must be the first element of the list). Elements of this list must be unique: a song can only be liked once. Liking a song twice should not impact the order. +- Each user can subscribe and unsubscribe to other users to see what they are listening to. This is stored in the user's _activity feed_. The items in the activity feed are sorted by reverse date of activity time (the last added activity must be the first element of the list). Items in this list should be unique by user id. If a new activity with a user id that is already in the list is added, the former should be removed, so that we always see the latest activity for each user we have subscribed to. + +This corresponds to the core features of Spotify: + +![](./spotify.jpg) + +Your task is to implement the receive method of the `User` actor. See the enums in the `User` what messages and responses a `User` should handle. + +You are allowed to add private methods and attributes to the `User` class. You can also import any Scala collection you might find useful. + +To implement the last part (problem 4.4), you will need to interact with the `SongStore` actor passed as the `songStore` parameter. You do not need it for the other parts. + +## Problem 4.1: Getting user info (50 points) + +Your first task is to implement the `User.receive` method so that it handles the `GetInfo` and `GetHomepageData` messages. This will allow you to pass the first 2 tests. + +## Problem 4.2: Updating user info (70 points) + +Your second task is to expand `User.receive` so that it handles the `Like` and `Unlike` messages. + +## Problem 4.3: Updating user info (70 points) + +Your third task is to expand `User.receive` so that it handles the `Subscribe`, `Unsubscribe`, `AddActivity` and `Play` messages. + +## Problem 4.4: Displaying the homepage (60 points) + +Your last (but not least!) task is to expand `User.receive` so that it handles the `GetHomepageText` message. + +A `GetHomepageText` should be answered with `HomepageText` message. Here is an example of a `HomepageText.result`: + +``` +Howdy Ada! + +Liked Songs: +* Sunny by Boney M. +* J'irai où tu iras by Céline Dion & Jean-Jacques Goldman +* Hold the line by TOTO + +Activity Feed: +* Bob is listening to Straight Edge by Minor Threat +* Donald is listening to Désenchantée by Mylène Farmer +* Carol is listening to Breakfast in America by Supertramp +``` + +More precisely, it should contains the following lines in order: + +1. `Howdy $name!`, where `$name` is the name of the recipient user. +2. A blank line +3. `Liked Songs:` +4. Zero or more lines listing the user's liked songs. Each of these lines should be of the form `"* ${song.title} by ${song.artist}`, where `${song.title}` is the title of the song and `${song.artist}` its artist. +5. A blank line +6. Zero or more lines listing the user activity feed items. Each of these lines should be of the form `* ${user.name} is listening to ${song.title} by ${song.artist}`, where `${user.name}` is the name of the user listening, `${song.title}` is the title of the song and `${song.artist}` its artist. + +In order to fetch the songs information (titles and artists), you should use the `songStore` actor passed as an argument to `User`. See the enums in the `SongsStore` companion object to learn how to interact with the song store. + +__Hint 1:__ to construct the result, you might find useful to use [`f-strings`](https://docs.scala-lang.org/overviews/core/string-interpolation.html#the-f-interpolator) and the [`List.mkString`](https://www.scala-lang.org/api/2.13.3/scala/collection/immutable/List.html#mkString(sep:String):String) method. Here is an example of how to use them: + +```scala +val fruits = List("Banana", "Apple", "Kiwi") +val result = f"""Fruits: +${fruits.map(fruit => f"* ${fruit}").mkString("\n")}""" + +assert(result == """Fruits: +* Banana +* Apple +* Kiwi""") +``` + +__Hint 2:__ if you need to send the result of a future to an actor, you should use the `pipeTo` method as described in the lectures and [here](https://doc.akka.io/docs/akka/2.5/futures.html#use-the-pipe-pattern). diff --git a/previous-exams/2022-final/concpar22final04/.gitignore b/previous-exams/2022-final/concpar22final04/.gitignore new file mode 100644 index 0000000..d094868 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/.gitignore @@ -0,0 +1,17 @@ +*.DS_Store +*.swp +*~ +*.class +*.tasty +target/ +logs/ +.bloop +.bsp +.dotty-ide-artifact +.dotty-ide.json +.idea +.metals +.vscode +*.csv +*.dat +metals.sbt diff --git a/previous-exams/2022-final/concpar22final04/assignment.sbt b/previous-exams/2022-final/concpar22final04/assignment.sbt new file mode 100644 index 0000000..70cbe95 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/assignment.sbt @@ -0,0 +1,5 @@ +// Student tasks (i.e. submit, packageSubmission) +enablePlugins(StudentTasks) + +assignmentVersion.withRank(KeyRanks.Invisible) := "39e6c8f1" + diff --git a/previous-exams/2022-final/concpar22final04/build.sbt b/previous-exams/2022-final/concpar22final04/build.sbt new file mode 100644 index 0000000..ea5fb5d --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/build.sbt @@ -0,0 +1,23 @@ +course := "concpar" +assignment := "concpar22final04" +scalaVersion := "3.1.0" + +scalacOptions ++= Seq("-language:implicitConversions", "-deprecation") +libraryDependencies += "org.scalameta" %% "munit" % "1.0.0-M3" % Test + +val akkaVersion = "2.6.19" +val logbackVersion = "1.2.11" +libraryDependencies ++= Seq( + "com.typesafe.akka" %% "akka-actor" % akkaVersion, + "com.typesafe.akka" %% "akka-testkit" % akkaVersion, + // SLF4J backend + // See https://doc.akka.io/docs/akka/current/typed/logging.html#slf4j-backend + "ch.qos.logback" % "logback-classic" % logbackVersion +) +fork := true +javaOptions ++= Seq("-Dakka.loglevel=Error", "-Dakka.actor.debug.receive=on") + +val MUnitFramework = new TestFramework("munit.Framework") +testFrameworks += MUnitFramework +// Decode Scala names +testOptions += Tests.Argument(MUnitFramework, "-s") diff --git a/previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala b/previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala new file mode 100644 index 0000000..0d5da7f --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/project/CourseraStudent.scala @@ -0,0 +1,212 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scala.util.{Failure, Success, Try} +import scalaj.http._ +import play.api.libs.json.{Json, JsObject, JsPath} + +/** + * Coursera uses two versions of each assignment. They both have the same assignment key and part id but have + * different item ids. + * + * @param key Assignment key + * @param partId Assignment partId + * @param itemId Item id of the non premium version + * @param premiumItemId Item id of the premium version (`None` if the assignment is optional) + */ +case class CourseraId(courseId: String, key: String, partId: String, itemId: String, premiumItemId: Option[String]) + + +object CourseraStudent extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val options = SettingKey[Map[String, Map[String, String]]]("options") + val courseraId = settingKey[CourseraId]("Coursera-specific information identifying the assignment") + // Convenient alias + type CourseraId = ch.epfl.lamp.CourseraId + val CourseraId = ch.epfl.lamp.CourseraId + } + + import StudentTasks.autoImport._ + import MOOCSettings.autoImport._ + import autoImport._ + + override lazy val projectSettings = Seq( + submitSetting, + ) + + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + // Fail if scalafix linting does not pass. + StudentTasks.scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val assignmentDetails = + courseraId.?.value.getOrElse(throw new MessageOnlyException("This assignment can not be submitted to Coursera because the `courseraId` setting is undefined")) + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "progfun1" => "scala-functional-programming" + case "progfun2" => "scala-functional-program-design" + case "parprog1" => "scala-parallel-programming" + case "bigdata" => "scala-spark-big-data" + case "capstone" => "scala-capstone" + case "reactive" => "scala-akka-reactive" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + StudentTasks.failSubmit() + } + + val base64Jar = StudentTasks.prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } + +} diff --git a/previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala b/previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala new file mode 100644 index 0000000..347cc6e --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/project/MOOCSettings.scala @@ -0,0 +1,51 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val datasetUrl = settingKey[String]("URL of the dataset used for testing") + val downloadDataset = taskKey[File]("Download the dataset required for the assignment") + val assignmentVersion = settingKey[String]("Hash string indicating the version of the assignment") + } + + import autoImport._ + + lazy val downloadDatasetDef = downloadDataset := { + val logger = streams.value.log + + datasetUrl.?.value match { + case Some(url) => + + import scalaj.http.Http + import sbt.io.IO + val dest = (Compile / resourceManaged).value / assignment.value / url.split("/").last + if (!dest.exists()) { + IO.touch(dest) + logger.info(s"Downloading $url") + val res = Http(url).method("GET") + val is = res.asBytes.body + IO.write(dest, is) + } + dest + case None => + logger.info(s"No dataset defined in datasetUrl") + throw new sbt.MessageOnlyException("No dataset to download for this assignment") + } + } + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + downloadDatasetDef, + Test / parallelExecution := false, + // Report test result after each test instead of waiting for every test to finish + Test / logBuffered := false, + name := s"${course.value}-${assignment.value}" + ) +} diff --git a/previous-exams/2022-final/concpar22final04/project/StudentTasks.scala b/previous-exams/2022-final/concpar22final04/project/StudentTasks.scala new file mode 100644 index 0000000..1ae03c1 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/project/StudentTasks.scala @@ -0,0 +1,150 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ +import scalafix.sbt.ScalafixPlugin.autoImport._ + +import java.io.{File, FileInputStream, IOException} +import org.apache.commons.codec.binary.Base64 + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + override def requires = super.requires && MOOCSettings + + object autoImport { + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + + val packageSubmission = inputKey[Unit]("package solution as an archive file") + lazy val Grading = config("grading") extend(Runtime) + } + + import autoImport._ + + // Run scalafix linting after compilation to avoid seeing parser errors twice + // Keep in sync with the use of scalafix in Grader + // (--exclude doesn't work (https://github.com/lampepfl-courses/moocs/pull/28#issuecomment-427894795) + // so we customize unmanagedSources below instead) + val scalafixLinting = Def.taskDyn { + if (new File(".scalafix.conf").exists()) { + (Compile / scalafix).toTask(" --check").dependsOn(Compile / compile) + } else Def.task(()) + } + + val testsJar = file("grading-tests.jar") + + override lazy val projectSettings = Seq( + // Run scalafix linting in parallel with the tests + (Test / test) := { + scalafixLinting.value + (Test / test).value + }, + + packageSubmissionSetting, + + fork := true, + run / connectInput := true, + outputStrategy := Some(StdoutOutput), + scalafixConfig := { + val scalafixDotConf = (baseDirectory.value / ".scalafix.conf") + if (scalafixDotConf.exists) Some(scalafixDotConf) else None + } + ) ++ packageSubmissionZipSettings ++ ( + if(testsJar.exists) inConfig(Grading)(Defaults.testSettings ++ Seq( + unmanagedJars += testsJar, + definedTests := (Test / definedTests).value, + internalDependencyClasspath := (Test / internalDependencyClasspath).value, + managedClasspath := (Test / managedClasspath).value, + )) + else Nil + ) + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (Compile / packageSourcesOnly).value + val binaries = (Compile / packageBinWithoutResources).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission, None) + submission + }, + packageSourcesOnly / artifactClassifier := Some("sources"), + Compile / packageBinWithoutResources / artifact ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (Compile / resources).value.flatMap(Path.relativeTo((Compile / resourceDirectories).value)(_)) + (Compile / packageBin / mappings).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + // Fail if scalafix linting does not pass. + scalafixLinting.value + + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (Compile / packageSubmissionZip).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/previous-exams/2022-final/concpar22final04/project/build.properties b/previous-exams/2022-final/concpar22final04/project/build.properties new file mode 100644 index 0000000..3161d21 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.6.1 diff --git a/previous-exams/2022-final/concpar22final04/project/buildSettings.sbt b/previous-exams/2022-final/concpar22final04/project/buildSettings.sbt new file mode 100644 index 0000000..1d98735 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/project/buildSettings.sbt @@ -0,0 +1,5 @@ +// Used for Coursera submission (StudentPlugin) +libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.2" +// Used for Base64 (StudentPlugin) +libraryDependencies += "commons-codec" % "commons-codec" % "1.15" \ No newline at end of file diff --git a/previous-exams/2022-final/concpar22final04/project/plugins.sbt b/previous-exams/2022-final/concpar22final04/project/plugins.sbt new file mode 100644 index 0000000..3c7aad8 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.26") +addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.8") diff --git a/previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala b/previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala new file mode 100644 index 0000000..5e54d55 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/src/main/scala/concpar22final04/Problem4.scala @@ -0,0 +1,179 @@ +package concpar22final04 + +import akka.actor.* +import akka.testkit.* +import java.util.Date +import akka.event.LoggingReceive +import akka.pattern.* +import akka.util.Timeout +import concurrent.duration.* +import scala.concurrent.Future +import scala.concurrent.ExecutionContext + +given timeout: Timeout = Timeout(200.millis) + +/** Data associated with a song: a unique `id`, a `title` and an `artist`. + */ +case class Song(id: Int, title: String, artist: String) + +/** An activity in a user's activity feed, representing that `userRef` is + * listening to `songId`. + */ +case class Activity(userId: String, userName: String, songId: Int) + +/** Companion object of the `User` class. + */ +object User: + /** Messages that can be sent to User actors. + */ + enum Protocol: + /** Asks for a user name and id. Should be answered by a Response.Info. + */ + case GetInfo + + /** Asks home page data. Should be answered by a Response.HomepageData. + */ + case GetHomepageData + + /** Like song with id `songId`. + */ + case Like(songId: Int) + + /** Unlike song with id `songId`. + */ + case Unlike(songId: Int) + + /** Adds `subscriber` to the list of subscribers. + */ + case Subscribe(subscriber: ActorRef) + + /** Remove `subscriber` from the list of subscribers. + */ + case Unsubscribe(subscriber: ActorRef) + + /** Adds the activity `activity` to the activity feed. This message will be + * sent by the users this user has subscribed to. + */ + case AddActivity(activity: Activity) + + /** Sent when a user starts playing a song with id `songId`. The recipient + * should notify all its subscribers to update their activity feeds by + * sending them `AddActivity(Activity(...))` messages. No answer is + * expected. This message is sent by external actors. + */ + case Play(songId: Int) + + /** Asks for home page text. Should be answered by a Response.HomepageText. + */ + case GetHomepageText + + /** Responses that can be sent back from User actors. + */ + enum Responses: + /** Answer to a Protocol.GetInfo message + */ + case Info(id: String, name: String) + + /** Answer to a Protocol.GetHomepageData message + */ + case HomepageData(songIds: List[Int], activities: List[Activity]) + + /** Answer to a Protocol.GetHomepageText message + */ + case HomepageText(result: String) + +/** The `User` actor, responsible to handle `User.Protocol` messages. + */ +class User(id: String, name: String, songsStore: ActorRef) extends Actor: + import User.* + import User.Protocol.* + import User.Responses.* + import SongsStore.Protocol.* + import SongsStore.Responses.* + + given ExecutionContext = context.system.dispatcher + + /** Liked songs, by reverse date of liking time (the last liked song must + * be the first must be the first element of the list). Elements of this + * list must be unique: a song can only be liked once. Liking a song + * twice should not change the order. + */ + var likedSongs: List[Int] = List() + + /** Users who have subscribed to this users. + */ + var subscribers: Set[ActorRef] = Set() + + /** Activity feed, by reverse date of activity time (the last added + * activity must be the first element of the list). Items in this list + * should be unique by `userRef`. If a new activity with a `userRef` + * already in the list is added, the former should be removed, so that we + * always see the latest activity for each user we have subscribed to. + */ + var activityFeed: List[Activity] = List() + + + /** This actor's behavior. */ + override def receive: Receive = ??? + +/** Objects containing the messages a songs store should handle. + */ +object SongsStore: + /** Ask information about a list of songs by their ids. + */ + enum Protocol: + case GetSongs(songIds: List[Int]) + + /** List of `Song` corresponding to the list of IDs given to `GetSongs`. + */ + enum Responses: + case Songs(songs: List[Song]) + +/** A mock implementation of a songs store. + */ +class MockSongsStore extends Actor: + import SongsStore.Protocol.* + import SongsStore.Responses.* + import SongsStore.* + + val songsDB = Map( + 1 -> Song(1, "High Hopes", "Pink Floyd"), + 2 -> Song(2, "Sunny", "Boney M."), + 3 -> Song(3, "J'irai où tu iras", "Céline Dion & Jean-Jacques Goldman"), + 4 -> Song(4, "Ce monde est cruel", "Vald"), + 5 -> Song(5, "Strobe", "deadmau5"), + 6 -> Song(6, "Désenchantée", "Mylène Farmer"), + 7 -> Song(7, "Straight Edge", "Minor Threat"), + 8 -> Song(8, "Hold the line", "TOTO"), + 9 -> Song(9, "Anarchy in the UK", "Sex Pistols"), + 10 -> Song(10, "Breakfast in America", "Supertramp") + ) + + override def receive: Receive = LoggingReceive { case GetSongs(songsIds) => + sender() ! Songs(songsIds.map(songsDB)) + } + +///////////////////////// +// DEBUG // +///////////////////////// + +/** Infrastructure to help debugging. In sbt use `run` to execute this code. + * The TestKit is an actor that can send messages and check the messages it receives (or not). + */ +@main def debug() = new TestKit(ActorSystem("DebugSystem")) with ImplicitSender: + import User.* + import User.Protocol.* + import User.Responses.* + import SongsStore.Protocol.* + import SongsStore.Responses.* + + try + val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore") + val anita = system.actorOf(Props(User("100", "Anita", songsStore))) + + anita ! Like(6) + expectNoMessage() // expects no message is received + + anita ! GetHomepageData + expectMsg(HomepageData(List(6), List())) + finally shutdown(system) diff --git a/previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala b/previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala new file mode 100644 index 0000000..f88d129 --- /dev/null +++ b/previous-exams/2022-final/concpar22final04/src/test/scala/concpar22final04/Problem4Suite.scala @@ -0,0 +1,361 @@ +package concpar22final04 + +import akka.actor.* +import akka.testkit.* +import akka.pattern.* +import akka.util.Timeout +import concurrent.duration.* +import User.Protocol.* +import User.Responses.* +import SongsStore.Protocol.* +import SongsStore.Responses.* +import scala.util.{Try, Success, Failure} +import com.typesafe.config.ConfigFactory +import java.util.Date +import scala.util.Random + +class Problem4Suite extends munit.FunSuite: +//--- + Random.setSeed(42178263) +/*+++ + Random.setSeed(42) +++*/ + + test("after receiving GetInfo, should answer with Info (20pts)") { + new MyTestKit: + def tests() = + ada ! GetInfo + expectMsg(Info("1", "Ada")) + } + + test("after receiving GetHomepageData, should answer with the correct HomepageData when there is no liked songs and no activity items (30pts)") { + new MyTestKit: + def tests() = + ada ! GetHomepageData + expectMsg(HomepageData(List(), List())) + } + + test("after receiving Like(1), should add 1 to the list of liked songs (20pts)") { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(1), List())) + } + + test( + "after receiving Like(1) and then Like(2), the list of liked songs should start with List(2, 1) (20pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(2, 1), List())) + } + + test( + "after receiving Like(1) and then Like(1), song 1 should be in the list of liked songs only once (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(1), List())) + } + + test( + "after receiving Like(1), Like(2) and then Like(1), the list of liked songs should start with List(2, 1) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(2, 1), List())) + } + + test( + "after receiving Like(1), Unlike(1) and then Unlike(1), the list of liked songs should not contain song 1 (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! Like(1) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(2, 1), List())) + } + + test( + "after receiving Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (20pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + } + + test( + "after receiving Subscribe(aUser), Subscribe(bUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + val donald = new TestProbe(system) + ada ! Subscribe(donald.ref) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + donald.expectMsg(AddActivity(Activity("1", "Ada", 5))) + } + + test( + "after receiving Subscribe(aUser), Subscribe(aUser) and then Play(5), should send AddActivity(Activity(\"1\", 5)) to aUser only once (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + ada ! Subscribe(self) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + expectNoMessage() + } + + test( + "after receiving Subscribe(aUser), Unsubscribe(aUser) and then Play(5), should not send AddActivity(Activity(\"1\", 5)) to aUser (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Subscribe(self) + expectNoMessage() + ada ! Play(5) + expectMsg(AddActivity(Activity("1", "Ada", 5))) + ada ! Unsubscribe(self) + expectNoMessage() + ada ! Play(5) + expectNoMessage() + } + + test( + "after receiving AddActivity(Activity(\"1\", 5)), Activity(\"1\", 5) should be in the activity feed (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! AddActivity(Activity("0", "Self", 5)) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(), List(Activity("0", "Self", 5)))) + } + + test( + "after receiving AddActivity(Activity(\"1\", 5)) and AddActivity(Activity(\"1\", 6)), Activity(\"1\", 6) should be in the activity feed and Activity(\"1\", 5) should not (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! AddActivity(Activity("0", "Self", 5)) + expectNoMessage() + ada ! AddActivity(Activity("0", "Self", 6)) + expectNoMessage() + ada ! GetHomepageData + expectMsg(HomepageData(List(), List(Activity("0", "Self", 6)))) + } + + test( + "after receiving GetHomepageText, should answer with a result containing \"Howdy $name!\" where $name is the user's name (10pts)" + ) { + new MyTestKit: + def tests() = + val name = Random.alphanumeric.take(5).mkString + val randomUser = system.actorOf(Props(classOf[User], "5", name, songsStore), "user-5") + randomUser ! GetHomepageText + expectMsgClass(classOf[HomepageText]).result.contains(f"Howdy $name!") + } + + test( + "after receiving GetHomepageText, should answer with the correct names of liked songs (1) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(8) + expectNoMessage() + ada ! Like(3) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(2) + .take(4) + .mkString("\n") + .trim, + """ + |Liked Songs: + |* Sunny by Boney M. + |* J'irai où tu iras by Céline Dion & Jean-Jacques Goldman + |* Hold the line by TOTO + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct names of liked songs (2) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(9) + expectNoMessage() + ada ! Like(7) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(2) + .take(3) + .mkString("\n") + .trim, + """ + |Liked Songs: + |* Straight Edge by Minor Threat + |* Anarchy in the UK by Sex Pistols + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct activity feed (1) (10pts)" + ) { + new MyTestKit: + def tests() = + bob ! Subscribe(ada) + expectNoMessage() + carol ! Subscribe(ada) + expectNoMessage() + donald ! Subscribe(ada) + expectNoMessage() + bob ! Play(3) + expectNoMessage() + carol ! Play(8) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(4) + .take(10) + .mkString("\n") + .trim, + """ + |Activity Feed: + |* Carol is listening to Hold the line by TOTO + |* Bob is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct activity feed (2) (10pts)" + ) { + new MyTestKit: + def tests() = + bob ! Subscribe(ada) + expectNoMessage() + carol ! Subscribe(ada) + expectNoMessage() + donald ! Subscribe(ada) + expectNoMessage() + bob ! Play(9) + expectNoMessage() + carol ! Play(10) + expectNoMessage() + donald ! Play(6) + expectNoMessage() + bob ! Play(7) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .drop(4) + .take(10) + .mkString("\n") + .trim, + """ + |Activity Feed: + |* Bob is listening to Straight Edge by Minor Threat + |* Donald is listening to Désenchantée by Mylène Farmer + |* Carol is listening to Breakfast in America by Supertramp + """.stripMargin.trim + ) + } + + test( + "after receiving GetHomepageText, should answer with the correct text (full test) (10pts)" + ) { + new MyTestKit: + def tests() = + ada ! Like(1) + expectNoMessage() + ada ! Like(2) + expectNoMessage() + bob ! Subscribe(ada) + expectNoMessage() + carol ! Subscribe(ada) + expectNoMessage() + donald ! Subscribe(ada) + expectNoMessage() + donald ! Play(3) + expectNoMessage() + bob ! Play(4) + expectNoMessage() + carol ! Play(5) + expectNoMessage() + ada ! GetHomepageText + assertEquals( + expectMsgClass(classOf[HomepageText]).result.linesIterator + .mkString("\n") + .trim, + """ + |Howdy Ada! + | + |Liked Songs: + |* Sunny by Boney M. + |* High Hopes by Pink Floyd + | + |Activity Feed: + |* Carol is listening to Strobe by deadmau5 + |* Bob is listening to Ce monde est cruel by Vald + |* Donald is listening to J'irai où tu iras by Céline Dion & Jean-Jacques Goldman + """.stripMargin.trim + ) + } + + abstract class MyTestKit + extends TestKit(ActorSystem("TestSystem")) + with ImplicitSender: + val songsStore = system.actorOf(Props(MockSongsStore()), "songsStore") + def makeAda() = system.actorOf(Props(classOf[User], "1", "Ada", songsStore), "user-1") + val ada = makeAda() + val bob = system.actorOf(Props(classOf[User], "2", "Bob", songsStore), "user-2") + val carol = system.actorOf(Props(classOf[User], "3", "Carol", songsStore), "user-3") + val donald = system.actorOf(Props(classOf[User], "4", "Donald", songsStore), "user-4") + def tests(): Unit + try tests() + finally shutdown(system) diff --git a/previous-exams/2022-final/spotify.jpg b/previous-exams/2022-final/spotify.jpg new file mode 100644 index 0000000000000000000000000000000000000000..79f91ad367a09ab3348ff0c167eaa9ddbdba8f37 GIT binary patch literal 343099 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

2i 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≤bNu7|zkumATekbD`}INtq07cS2zzo1|A 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 s!5y;s8@mSyj7zmk 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