[LIVY-41] Let users access sessions by session name#48
Conversation
This commit enables Livy users to access sessions either by names or by auto-generated sessiond id's. It also prevents users from creating sessions that have the same name. This commit keeps API change minimal. Thse are palces that API change is needed: - `Session` and its subclasses adds a new field, `name`. - `RecoveryMetadata` and its subclasses adds a new field, `name`. - `SessionManager` adds a new method `getSession(name: String)` which lookups sessions by name. A more clean implementation would change the signature of `SessionManager.register` so it returns a proper container around the session value to determine if it failed to register the given session. For example, ```Scala def register(session S): Either[S, Throwable] ``` Task-url: https://issues.apache.org/jira/browse/LIVY-41
Codecov Report
@@ Coverage Diff @@
## master #48 +/- ##
============================================
+ Coverage 68.34% 68.71% +0.36%
- Complexity 895 902 +7
============================================
Files 100 100
Lines 5604 5644 +40
Branches 840 847 +7
============================================
+ Hits 3830 3878 +48
+ Misses 1225 1211 -14
- Partials 549 555 +6
Continue to review full report at Codecov.
|
ajbozarth
left a comment
There was a problem hiding this comment.
I made a handful of comments, still not 100% behind this, will need some time to think about it.
A couple of general comments:
You'll need to update the Docs to match the API updates.
I think you should use a uniform naming convention for the test classes, it seems each class has it's own naming convention.
| checkFn: Option[(String, HttpServletRequest) => Boolean]): Any = { | ||
| val sessionId = params("id").toInt | ||
| sessionManager.get(sessionId) match { | ||
| val idParam: String = params("id") |
There was a problem hiding this comment.
I don't think re-using the id param for name is a good idea. It should have it's own optional param instead. I believe this is already how you are doing session creation anyway.
There was a problem hiding this comment.
I don't have any strong opinion for or against reusing id param. Here is why:
If we do not reuse id param, we have to create a new REST endpoint for each existing one. Reusing id param allows Livy users access a session seamlessly by its ID or its name through the same endpoint:
sessions/{id} or sessions/{name}
sessions/{id}/statements or sessions/{name}/statements
sessions/{id}/state or sessions/{name}/state
sessions/{id}/log or sessions/{name}/log
If we do not reuse id param, we have to add separate endpoints for accessing sessions by name. Something like
named-sessions/{name}
named-sessions/{name}/statements
named-sessions/{name}/state
named-sessions/{name}/log
or
sessions/by-name/{name}
sessions/by-name/{name}/statements
sessions/by-name/{name}/state
sessions/by-name/{name}/log
or
sessions?name={name}
sessions/statements?name={name}
sessions/state?name={name}
sessions/log?name={name}
@ajbozarth, what are your thoughts on adding a new endpoint?
There was a problem hiding this comment.
I checked the code to followup my argument and realized that the way these end-points are written the way you did it is actually the best way. The param name id is not user-facing so the clarity I want can be made in the Docs rather than the code. Given this change to the API though I would like you to double check that the Web UI will support these changes, both that it still works with the id, but also works by using a session name in the url. Actually adding the Session name to the UI can be done in a followup task (which I'm willing to do).
There was a problem hiding this comment.
Let's rename this variable as idOrNameParam to be clear.
| val sessionName: String = createRequest.name match { | ||
| case Some(name) if sessionManager.get(name).isEmpty => | ||
| name | ||
| case Some(name) => |
There was a problem hiding this comment.
I think this should be an else statement in the first case instead. This usage of match is a bit hacky.
There was a problem hiding this comment.
👍 I'll update this even though I am an advocate of if-guards in case statements.
There was a problem hiding this comment.
I was thinking an if/else inside the Some(name) case.
| s"Choose a different name or delete the existing session." | ||
| throw new IllegalArgumentException(msg) | ||
| case None => | ||
| s"INTERACTIVE-SESSION-$sessionId" |
There was a problem hiding this comment.
This wasn't changed in the copy-paste. Also I'm not a fan of this default name, it's a bit verbose. Just batch-$sessionId would suffice.
| s"Choose a different name or delete the existing session." | ||
| throw new IllegalArgumentException(msg) | ||
| case None => | ||
| s"INTERACTIVE-SESSION-$sessionId" |
There was a problem hiding this comment.
As above, just session-$sessionId would suffice.
| val sessionName: String = createRequest.name match { | ||
| case Some(name) if sessionManager.get(name).isEmpty => | ||
| name | ||
| case Some(name) => |
| // Livy checks to make sure another session with the same name does not exist already. | ||
| // This case should not happen rarely. | ||
| // already exists. But looking up a session name and adding it to the set of existing sessions | ||
| // should happen atomically. |
There was a problem hiding this comment.
Theres some confusing wording this comment block, might be artifacts from changes during development, but it makes the comment difficult to understand.
| if (sessionsByName.contains(session.name)) { | ||
| val msg = s"Session ${session.name} already exists!" | ||
| error(msg) | ||
| delete(session) |
There was a problem hiding this comment.
Discussion Point: Do we want to fail a request with a "bad" name or should we instead give a warning message and create a session with a default name instead? Just an idea, I'm still not 100% sure of this PR's use case.
There was a problem hiding this comment.
This is an example use case we have here. I'll ask Prabhu to provide more.
Team AAA has an ETL pipeline everyday at 1pm. The pipeline is a chain of jobs that work on the same dataset. They want to create a session on Livy and use it to run the pipeline. They also want to push the metrics and stats of their pipeline to alerting and monitoring services.
This PR allows them to access the session, manage it, and monitor it with a human readable name like team_aaa_daily_etl.
There was a problem hiding this comment.
Your example use case does help me with understanding this PR's purpose, which I am liking more, but not this particular code decision. Is there a good reason we should be failing session creation on a "bad" session name rather than displaying a warning that the name was bad then creating a session with the default naming? I'm not saying either way is better, I am just wondering you reason for doing it this way.
There was a problem hiding this comment.
I think fail the request and return user the specific bad http code would be better then choosing another name for user. Because user want to create a session with name "A" for example, if Livy cannot satisfy user's requirement, then user should choose another name. It is better to let user to decide than letting Livy to handle it.
|
@meisam , do you need to change Java and python job API to support this feature? As I remembered it can specify session id to reuse the existing session, so for now we should also support session name. |
|
@jerryshao Yes, changing the Java and python API is a good idea. |
|
IMPO, for quicker pr reviews, I think adding Java and python API support can be done in a followup PR |
| } | ||
|
|
||
| def startBatch( | ||
| name: String, |
There was a problem hiding this comment.
I would suggest to use Option[String] instead to avoid explicitly set to "null" as below.
There was a problem hiding this comment.
I'll update it to avoid explicit null values.
| checkFn: Option[(String, HttpServletRequest) => Boolean]): Any = { | ||
| val sessionId = params("id").toInt | ||
| sessionManager.get(sessionId) match { | ||
| val idParam: String = params("id") |
There was a problem hiding this comment.
Let's rename this variable as idOrNameParam to be clear.
| // it prevents Livy from submitting applications to Spark. | ||
| val msg = s"Session $name already exists! " + | ||
| s"Choose a different name or delete the existing session." | ||
| throw new IllegalArgumentException(msg) |
There was a problem hiding this comment.
Is the exception here OK, shall we return some HTTP code to rest client about session name duplication?
There was a problem hiding this comment.
nice catch, I not sure this would be surfaced to the submitter or not
There was a problem hiding this comment.
One case that we should keep in mind about returning an error code.
Assume the existing session s with name daily-job.
If the owner s is requesting a new session with name daily-job, we will reach this point and the user gets a http 400 bad request.
But if a different user is requesting a session s2 with name daily-job, the user gets a 403 Forbidden before reaching this point, because he/she does not have access to /sessions/daily-job/.
| val proxyUser = checkImpersonation(createRequest.proxyUser, req) | ||
| val sessionId: Int = sessionManager.nextId() | ||
| val sessionName: String = createRequest.name match { | ||
| case Some(name) if sessionManager.get(name).isEmpty => |
There was a problem hiding this comment.
We'd better put this code into common place for both interactive and batch session.
There was a problem hiding this comment.
It is refactored into one place now.
|
|
||
| override def recoveryMetadata: RecoveryMetadata = | ||
| BatchRecoveryMetadata(id, appId, appTag, owner, proxyUser) | ||
| BatchRecoveryMetadata(id, name, appId, appTag, owner, proxyUser) |
There was a problem hiding this comment.
@meisam what if old recovery metadata which doesn't have name field, I think we should consider the compatibility of old metadata file with new Livy server, would you please check it?
There was a problem hiding this comment.
What do you suggest? Should we assign such sessions auto-generated names on recovery? Or should we leave them without a name?
There was a problem hiding this comment.
I'm saying with your changes, if new Livy server tries to recover from old data, what will be happened? Will it be failed?
Since the old recovery data doesn't have a session name field, so we could offer an auto-generated name, but we should not fail such scenario.
There was a problem hiding this comment.
I'll add test cases to confirm that Livy can recover from old data.
|
|
||
| protected[this] final val idCounter = new AtomicInteger(0) | ||
| protected[this] final val sessions = mutable.LinkedHashMap[Int, S]() | ||
| private[this] final val sessionsByName = mutable.Map[String, S]() |
There was a problem hiding this comment.
I think we can use mutable.HashMap to be clear.
| if (sessionsByName.contains(session.name)) { | ||
| val msg = s"Session ${session.name} already exists!" | ||
| error(msg) | ||
| delete(session) |
There was a problem hiding this comment.
I think fail the request and return user the specific bad http code would be better then choosing another name for user. Because user want to create a session with name "A" for example, if Livy cannot satisfy user's requirement, then user should choose another name. It is better to let user to decide than letting Livy to handle it.
I like failing the request option more. The only caveat is that with current approach, sometimes we submit the spark App and then kill it later (if two requests arrive at almost the same time, we launch both, but later when we want to add them to sessionManager, we have to keep only one and kill and and discard the other one). One way to avoid that is to refactor Are you okay with this approach? |
I would have to see the change first but I think this could potentially be ok |
That's the problem of concurrency, I think we can first check the validity of session name, if it is conflicted, then directly return bad code. We should not postpone such check until session is created. |
@meisam I would suggest to leave this work to another PR for the simplicity of review. |
Signed-off-by: Fathi Salmi, Meisam(mfathisalmi) <mfathisalmi@paypal.com>
Changing ``` case ... if .... => ``` to ``` case ... => if ... ```
|
@meisam can you please address the comments, thanks! |
If no session name is provided, do not use session names. This makes it possible to recover sessions that were stored with no name.
|
This is the output from last build:
Closing and re-opening this pull request to trigger a new build. |
@jerryshao I addressed comments in the previous commit. |
| (scriptPath: String, args: List[String], sparkConf: Map[String, String] = Map.empty) | ||
| (f: (LivyRestClient#BatchSession) => R): R = { | ||
| val s = livyClient.startBatch(scriptPath, None, args, sparkConf) | ||
| val s = livyClient.startBatch(null, scriptPath, None, args, sparkConf) |
There was a problem hiding this comment.
Should here change to None?
There was a problem hiding this comment.
That's right. I prefer passing None to passing null. I'll fix it.
| (testClass: Class[_], args: List[String], sparkConf: Map[String, String] = Map.empty) | ||
| (f: (LivyRestClient#BatchSession) => R): R = { | ||
| val s = livyClient.startBatch(testLibPath, Some(testClass.getName()), args, sparkConf) | ||
| val s = livyClient.startBatch(null, testLibPath, Some(testClass.getName()), args, sparkConf) |
There was a problem hiding this comment.
I'll fix this one too.
| heartbeatTimeoutInSecond: Int = 0) | ||
| (f: (LivyRestClient#InteractiveSession) => R): R = { | ||
| withSession(livyClient.startSession(kind, sparkConf, heartbeatTimeoutInSecond)) { s => | ||
| withSession(livyClient.startSession(null, kind, sparkConf, heartbeatTimeoutInSecond)) { s => |
| sessionStore.save(RECOVERY_SESSION_TYPE, recoveryMetadata) | ||
| heartbeat() | ||
|
|
||
| private val app = mockApp.orElse { |
There was a problem hiding this comment.
What's the purpose of below change?
There was a problem hiding this comment.
Before this change, Livy immediately submits the Spark app upon receiving the request. After this change, Livy has to call Session.start() to submit the Spark app, which means Livy can make sure another session with the same name does not exist before submitting the Spark app.
Without this change, making session names unique is complicated, because if two session names collide, we have only two choices:
- Kill one of the sessions.
- Come up with a logic that gives one of the sessions an autogenerated name and guarantees the autogenerated name does not collide with any of the existing/future session names.
There was a problem hiding this comment.
Thanks for the explanation, let me review again.
|
Is there a plan to merge this PR? If so, I can fix the recent merge conflicts. Otherwise we should close it for good. |
vanzin
left a comment
There was a problem hiding this comment.
There seems to be previous feedback that hasn't been addressed.
I took a quick look but it seems to me this is putting both IDs and names into the same namespace. Except I didn't notice any enforcement that IDs and names do not conflict. So if I create a session with name "1", and a later request makes Livy try to create a session with ID 1, what happens?
I think it would be better to keep both concepts separate (e.g. how on /dev/disk you have multiple non-conflicting ways of addressing the same disk).
| val driverProcess = client.flatMap { c => Option(c.getDriverProcess) } | ||
| .map(new LineBufferedProcess(_, livyConf.getInt(LivyConf.SPARK_LOGS_SIZE))) | ||
| driverProcess.map { _ => SparkApp.create(appTag, appId, driverProcess, livyConf, Some(this)) | ||
| } |
There was a problem hiding this comment.
either in previous line or, if it doesn't fit, use multi-line syntax for the whole closure.
There was a problem hiding this comment.
In these lines, I only changed the indentation. I think formatting issues should be handled in a separate PR if needed.
| } else { | ||
| val uriFuture = Future { client.get.getServerUri.get() } | ||
|
|
||
| uriFuture onSuccess { case url => |
There was a problem hiding this comment.
uriFuture.onSuccess, also in other places
| def register(session: S): S = { | ||
| info(s"Registering new session ${session.id}") | ||
| synchronized { | ||
| session.name.map { sessionName => |
| val sessionId = params("id").toInt | ||
| sessionManager.get(sessionId) match { | ||
| val idOrNameParam: String = params("id") | ||
| val session = if (idOrNameParam.forall(_.isDigit)) { |
There was a problem hiding this comment.
You're assuming that a string with all digits is an ID and not a name. I don't see that enforced when creating a session?
There was a problem hiding this comment.
I changed this to force session names to have at least one non-digit character.
Session names cannot be numbers.
# Conflicts: # server/src/main/scala/org/apache/livy/server/SessionServlet.scala # server/src/main/scala/org/apache/livy/server/batch/BatchSessionServlet.scala # server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala # server/src/test/scala/org/apache/livy/server/batch/BatchServletSpec.scala # server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala # server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionServletSpec.scala # server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala
A session name should have at least one non digit character in it.
| str.nonEmpty && str.forall(_.isDigit) | ||
| } | ||
|
|
||
| name.foreach { idStr => |
There was a problem hiding this comment.
idStr is a misleading name since this is not an id.
There was a problem hiding this comment.
renamed it to sessionName
| protected implicit val executionContext = ExecutionContext.global | ||
|
|
||
| private def isSessionId(str: String): Boolean = { | ||
| str.nonEmpty && str.forall(_.isDigit) |
There was a problem hiding this comment.
An empty name should be invalid too, right?
I think it would be clearer to have this code inlined below, as I suggested before. Then that whole block becomes the clear place where session name validation occurs.
There was a problem hiding this comment.
An empty name should be invalid too, right?
Right.
While testing what is a good way to separate session names from session IDs, I saw two interesting behaviors in Livy (and Scala/Java).
Char.isDigitreturnstruefor digits in all alphabets systems. For example:
scala> "᧑᧒᧓᧔᧕".forall(_.isDigit)
res18: Boolean = true
scala> "᧑᧒᧓᧔᧕".toInt
res19: Int = 12345
scala> "᧑᧒᧓᧔᧕".map(c => Character.getName(c.toInt))
res20: scala.collection.immutable.IndexedSeq[String] = Vector(NEW TAI LUE DIGIT ONE, NEW TAI LUE DIGIT TWO, NEW TAI LUE DIGIT THREE, NEW TAI LUE DIGIT FOUR, NEW TAI LUE DIGIT FIVE)
- If I create a session with
sessionID=1all these are valid endpoints to the session:
${LIVY_SERVER}:${LIVY_PORT}/sessions/1
${LIVY_SERVER}:${LIVY_PORT}/sessions/01
${LIVY_SERVER}:${LIVY_PORT}/sessions/001
${LIVY_SERVER}:${LIVY_PORT}/sessions/00001
${LIVY_SERVER}:${LIVY_PORT}/sessions/۱
${LIVY_SERVER}:${LIVY_PORT}/sessions/0۱
${LIVY_SERVER}:${LIVY_PORT}/sessions/۰۰۰۱
${LIVY_SERVER}:${LIVY_PORT}/sessions/0۰۰00۰᧑
Should we keep this behavior as is?
There was a problem hiding this comment.
For now, disallowing empty strings as session names.
| view.log shouldEqual log | ||
| } | ||
|
|
||
| it("should show session properties for sessions without a name") { |
There was a problem hiding this comment.
This looks very much like the test above. You could have:
Seq(
(Some(id), None),
(None, Some(name)
).foreach { case (id, name) =>
it("should show session properties (id = $id, name = $name)") {
methodWithTestImpl(id, name)
}
}
There was a problem hiding this comment.
refactored into one helper method.
| val batch = BatchSession.recover(m, conf, sessionStore, Some(mockApp)) | ||
|
|
||
| batch.state shouldBe (SessionState.Recovering) | ||
|
|
There was a problem hiding this comment.
Check that the recovered session is named?
| view.log shouldEqual log.asJava | ||
| } | ||
|
|
||
| it("should show session properties for sessions without a name") { |
There was a problem hiding this comment.
Same thing here. Better to avoid copy & pasting the existing test and changing a couple of lines.
There was a problem hiding this comment.
refactored into one helper method.
| MockitoMatchers.eq(InteractiveSession.RECOVERY_SESSION_TYPE), anyObject()) | ||
| } | ||
|
|
||
| it("should recover sessions with no name") { |
There was a problem hiding this comment.
Another one that looks like copy & paste of the existing test.
There was a problem hiding this comment.
refactored into one helper method.
| | "version": 1 | ||
| |} | ||
| """.stripMargin | ||
| val batchRecoveryMetadata = stateStore.deserialize[BatchRecoveryMetadata](json.getBytes()) |
| an[IllegalArgumentException] should be thrownBy manager.register(session2) | ||
| manager.get(session1.id).isDefined should be(true) | ||
| manager.get(session2.id).isDefined should be(false) | ||
| eventually(timeout(5 seconds), interval(100 millis)) { |
There was a problem hiding this comment.
This test is not needed here, right? It has nothing to do with the session name.
There was a problem hiding this comment.
removed the garbage collection section.
| val livyConf = new LivyConf() | ||
| val name = "Mock-session" | ||
| livyConf.set(LivyConf.SESSION_TIMEOUT, "100ms") | ||
| val manager = new SessionManager[MockSession, RecoveryMetadata]( |
There was a problem hiding this comment.
This is now in 3 different places in this file. Should probably be a helper method.
|
Have we considered the potential impact of this change for Livy multi-master HA? |
Very good point. |
|
Just to refresh my memory: is multi-master HA a thing? I looked at how session IDs are created today and it doesn't really seem that atomic when you have multiple servers looking at the store. In fact it doesn't seem that atomic even with a single server: That can write the wrong id to the session store if two threads race in just the wrong way. Anyway, if multi-master is not a thing today, then this looks ok to me. |
There is a separate PR to make sessionIDs atomic across multiple servers. |
|
So I guess pushing this change will require changes in that PR, or vice-versa. Seems this one is farther along. |
|
I didn't mean it's needed. I'm saying that the "how to have unique names with multiple servers" issue becomes a problem when either PR goes in. So when I push this, that problem will have to be solved in the other PR. Or vice-versa, if the other PR went in first. |
|
Merging to master. |
This commit enables Livy users to access sessions either by names or by auto-generated sessiond id's. It also prevents users from creating sessions that have the same name. This commit keeps API change minimal. These are places that API change is needed: - `Session` and its sub-classes adds a new field, `name`. - `RecoveryMetadata` and its sub-classes adds a new field, `name`. - `SessionManager` adds a new method `getSession(name: String)` which looks sessions up by name. Task-url: https://issues.apache.org/jira/browse/LIVY-41 Author: Fathi Salmi, Meisam(mfathisalmi) <mfathisalmi@paypal.com> Author: Meisam Fathi <meisam.fathi@gmail.com> Author: Fathi Salmi, Meisam <meisam.fathi@gmail.com> Author: Fathi, Meisam <meisam.fathi@gmail.com> Closes apache#48 from meisam/LIVY-41-rebased.
This commit enables Livy users to access sessions either by names or by auto-generated sessiond id's. It also prevents users from creating sessions that have the same name. This commit keeps API change minimal. These are places that API change is needed: - `Session` and its sub-classes adds a new field, `name`. - `RecoveryMetadata` and its sub-classes adds a new field, `name`. - `SessionManager` adds a new method `getSession(name: String)` which looks sessions up by name. Task-url: https://issues.apache.org/jira/browse/LIVY-41 Author: Fathi Salmi, Meisam(mfathisalmi) <mfathisalmi@paypal.com> Author: Meisam Fathi <meisam.fathi@gmail.com> Author: Fathi Salmi, Meisam <meisam.fathi@gmail.com> Author: Fathi, Meisam <meisam.fathi@gmail.com> Closes apache#48 from meisam/LIVY-41-rebased.
This commit enables Livy users to access sessions either by names or by auto-generated sessiond id's.
It also prevents users from creating sessions that have the same name.
This commit keeps API change minimal. These are places that API change
is needed:
Sessionand its sub-classes adds a new field,name.RecoveryMetadataand its sub-classes adds a new field,name.SessionManageradds a new methodgetSession(name: String)which looks sessions up by name.Task-url: https://issues.apache.org/jira/browse/LIVY-41