Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

refactor(robot-server): add PE commands to /sessions responses #8006

Merged
merged 4 commits into from
Jun 29, 2021

Conversation

mcous
Copy link
Contributor

@mcous mcous commented Jun 24, 2021

Overview

This PR builds on the models added to the ProtocolEngine by #7993 and adds ProtocolEngine commands to the (currently feature-flagged) /sessions endpoints. It also adds two new endpoints:

  • GET /sessions/:session_id/commands
  • GET /sessions/:session_id/commands/:command_id

Closes #7871.

Changelog

  • Add SessionCommandSummary models to GET /sessions and GET /sessions/:session_id
  • Add new GET /sessions/:session_id/commands endpoint that returns SessionCommandSummary models
  • Add new GET /sessions/:session_id/commands/:command_id endpoint that returns full protocol_engine.Command models

In real world testing, serializing all the commands of even a simple JSON protocol in full took a long time (1.5s response time on an OT-2). So, after disucssions in Slack, I decided to create a minimal SessionCommandSummary model for the collection endpoints. We should expect to revisit this decision as development progresses, but for now I think it's a reasonable compromise.

Review requests

  • Everything works as advertised (pending test plan)
  • Code looks good
  • Tests look good
  • Generated OpenAPI Spec makes sense

Smoke test plan

I've updated the Postman sessions testing collection with:

  • A simple JSON test protocol (Simple Test Protocol.json)
  • The new endpoints listed above
  • Automatic setting of the command_id variable to the first command in the POST /sessions response, if present

Using this collection, this is the test smoke test procedure I've been running on the dev server and a real robot:

  1. Upload Simple Test Protocol.json using a POST /protocols request
  2. Create a protocol session using a POST /sessions { sessionType: protocol } request
    • Verify command summaries are in data.commands
  3. Fetch all commands using a GET /sessions/:session_id/commands request
    • Verify command summaries are in data
  4. Fetch a full command using a GET /sessions/:session_id/commands/:command_id request
    • Verify full command model from ProtocolEngine is in data

Risk assessment

N/A; work is entirely feature flagged

@mcous mcous added robot-svcs Falls under the purview of the Robot Services squad (formerly CPX, Core Platform Experience). protocol-engine Ticket related to the Protocol Engine project and associated HTTP APIs labels Jun 24, 2021
@codecov
Copy link

codecov bot commented Jun 24, 2021

Codecov Report

Merging #8006 (04b707b) into edge (3f74c4a) will decrease coverage by 0.04%.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff             @@
##             edge    #8006      +/-   ##
==========================================
- Coverage   85.93%   85.89%   -0.05%     
==========================================
  Files         351      352       +1     
  Lines       21351    21418      +67     
==========================================
+ Hits        18347    18396      +49     
- Misses       3004     3022      +18     
Impacted Files Coverage Δ
...t-server/robot_server/service/json_api/response.py 100.00% <100.00%> (ø)
robot-server/robot_server/sessions/router.py 100.00% <100.00%> (ø)
...obot-server/robot_server/sessions/schema_models.py 100.00% <100.00%> (ø)
...bot-server/robot_server/sessions/session_models.py 100.00% <100.00%> (ø)
robot-server/robot_server/sessions/session_view.py 96.00% <100.00%> (+0.34%) ⬆️
opentrons/calibration_storage/get.py 61.53% <0.00%> (-5.13%) ⬇️
opentrons/protocol_engine/__init__.py 100.00% <0.00%> (ø)
opentrons/protocol_engine/commands/__init__.py 100.00% <0.00%> (ø)
...entrons/protocol_engine/commands/command_unions.py 100.00% <0.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3f74c4a...04b707b. Read the comment docs.

Copy link
Contributor Author

@mcous mcous left a comment

Choose a reason for hiding this comment

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

Highlights from this PR

@@ -56,7 +56,7 @@ def add_command(self, request: CommandRequest) -> Command:

async def execute_command_by_id(self, command_id: str) -> Command:
"""Execute a protocol engine command by its identifier."""
queued_command = self.state_view.commands.get_command_by_id(command_id)
queued_command = self.state_view.commands.get(command_id)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In usage, state_view.commands.get_command_by_id felt quite excessive. In line with similar feedback in the past, I renamed the methods of the CommandView to remove command, given that they're all accessed via state_view.commands:

before after
commands.get_command_by_id commands.get
commands.get_all_commands commands.get_all
commands.get_next_command commands.get_next_queued

Copy link
Contributor

Choose a reason for hiding this comment

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

This feels much better to me.

If we wanted to go even further, how would we feel about replacing commands.get(id) with commands[id]?

Copy link
Contributor Author

@mcous mcous Jun 29, 2021

Choose a reason for hiding this comment

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

I'm anti-bracket-notation for this interface. Supporting bracket notation would bring in magic (methods) that just aren't needed and don't (IMO) meaningfully improve the DX of this class.

  • It would make testing harder (or at least inconsistent, where we can no longer do a simple function mock)
  • It makes it less obvious that commands.get(id) may be a computed value
    • If, for example, different parts of a given Command were stored in different places, and get pulled them all together
    • This isn't outside the realm of possibility
  • You can already do command_state.commands_by_id[id]

@@ -22,7 +22,7 @@ class ResourceModel(ResponseDataModel):
id: str = Field(..., description="Unique identifier of the resource.")


ResponseDataT = TypeVar("ResponseDataT", bound=ResponseDataModel)
ResponseDataT = TypeVar("ResponseDataT", bound=BaseModel)
Copy link
Contributor Author

@mcous mcous Jun 24, 2021

Choose a reason for hiding this comment

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

Went down a bit of a rabbit hole on this one!

  • This TypeVar is used by the generic ResponseModel to say that its data field must be a ResponseDataModel
  • ResponseDataModel is a class in the robot_server package that is a BaseModel with an id field defined
  • We want to put protocol_engine.Command models in HTTP ResponseModels
    • protocol_engine.Command is a BaseModel with an ID field defined
    • However, it cannot inherit from robot_server.ResponseDataModel because it's in the opentrons package (and that would introduce a circular dependency)

So, I saw two options available:

  1. Move the responsibility of ResponseDataModel into the opentrons package as some basic resource base class
  2. Downgrade this to a BaseModel and rely on server conventions to ensure all responses have IDs

I went with (2) because it was just way easier. I also explored a third option:

  1. Create a resource Protocol and set this TypeVar to say that data must be both ResourceLike (an object with an id property) and a BaseModel

Option 3 doesn't work because of fundamental limitations in the Python typing system:

  • There's no Intersection type to say TypeA & TypeB
  • You can't create a Protocol from an actual class

Longer term (assuming Option 3 never pans out), I can see us doing a version of option 1, especially if discussions continue on some sort of OpentronsBaseModel

},
)
async def get_session_commands(
session: ResponseModel[Session] = Depends(get_session),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can't decide if this is great or awful, but turns out, you can throw an entire route handler into Depends to compose routes. Here (and in the route below) it allows us to avoid repeating ourselves with SessionNotFound responses.

If this bothers you, I'm happy to revert and make this more verbose / repeat ourselves a little

Copy link
Contributor

@SyntaxColoring SyntaxColoring Jun 28, 2021

Choose a reason for hiding this comment

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

Off the top of my head, I do not understand this, but I'll look at it again tomorrow morning when I'm smarter.

Copy link
Contributor

@SyntaxColoring SyntaxColoring Jun 29, 2021

Choose a reason for hiding this comment

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

Aha okay I get it now.

Yeah, uh, I also can't tell if this is great or awful, lol. I don't hate it!

Would it feel better if, instead of this:

get_session_commands()
    |
    V
get_session()

It were this?

get_session_commands()    get_session()
                      \   /
                       V V
          _internal_get_session()

I don't mind it staying as-is for this PR, if you want to merge today.

ResponseModel[ProtocolSession],
]

SessionCommandResponse = Union[
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is tedious, but it's also really important if we want our OpenAPI Spec to be reasonable. The difference between...

  1. ResponseModel[Union[CommandA, CommandB, ...]]
  2. Union[ResponseModel[CommandA], ResponseModel[CommandB], ...]]

...is huge in terms of the generated spec, even though they're mathematically identical. The OAS from option (1) is unusable, wheras (2) generates a nice set of reasonable types in the schema.

If we had known about this when we were evaluating response shapes, I think this would've been a huge 👎 against going with JSON:API-style responses (i.e. { data: ... }. Unfortunately, that ship's kind of sailed, so I think we're stuck with this for the time being

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah. I don't know if this is the same problem you're talking about, but this is basically how I imagined we'd fix our possible links values not being specified in the OpenAPI spec.

client: TestClient,
) -> None:
"""It should return a list of all commands in a session."""
session = SessionResource(
Copy link
Contributor Author

@mcous mcous Jun 24, 2021

Choose a reason for hiding this comment

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

Could probably DRY these command route tests up a little. These stubs are common between all or most of the 200 tests


Edit: on second thought, explicit (with duplication) is probably fine for now

Base automatically changed from engine_simplify-command-models to edge June 26, 2021 00:28
@mcous mcous force-pushed the robot-server_session-commands branch from a186775 to ad7d37d Compare June 26, 2021 00:29
@mcous mcous marked this pull request as ready for review June 28, 2021 14:49
@mcous mcous requested a review from a team as a code owner June 28, 2021 14:49
@mcous mcous requested review from sanni-t and SyntaxColoring June 28, 2021 14:49
Copy link
Contributor

@SyntaxColoring SyntaxColoring left a comment

Choose a reason for hiding this comment

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

🕺

return self._engine.state_view.commands.get_next_command()
return self._engine.state_view.commands.get_next_queued()
Copy link
Contributor

Choose a reason for hiding this comment

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

Very nice.

@@ -56,7 +56,7 @@ def add_command(self, request: CommandRequest) -> Command:

async def execute_command_by_id(self, command_id: str) -> Command:
"""Execute a protocol engine command by its identifier."""
queued_command = self.state_view.commands.get_command_by_id(command_id)
queued_command = self.state_view.commands.get(command_id)
Copy link
Contributor

Choose a reason for hiding this comment

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

This feels much better to me.

If we wanted to go even further, how would we feel about replacing commands.get(id) with commands[id]?

api/src/opentrons/protocol_engine/state/commands.py Outdated Show resolved Hide resolved
Comment on lines +71 to +73
# TODO(mc, 2021-06-23): mypy >= 0.780 broke Unions as `response_model`
# see https://github.com/tiangolo/fastapi/issues/2279
response_model=SessionResponse, # type: ignore[arg-type]
Copy link
Contributor

Choose a reason for hiding this comment

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

Not for this PR, but maybe we should in general just not use response_model, and instead do:

    responses={
        status.HTTP_200_SUCCESS: {"model": Union[Success1, Success2]}, # I'm assuming this works.
        status.HTTP_404_NOT_FOUND: {"model": ErrorResponse[ProtocolNotFound]},
        status.HTTP_409_CONFLICT: {"model": ErrorResponse[SessionAlreadyActive]},
    },

response_model always weirded me out anyway: why is there one special success response? Especially given our error handling strategy of not raiseing HTTP errors from anything but the top level.

response_model's use seems to be automatically converting the function's return value to the given class. But we don't do that—we always return the exact, explicit model class that we want. e.g. we return Foo(bar="baz") instead of {"bar": "baz"}.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've played around with this, and I walked away having experienced a bad time. It messes with response validation and possibly also the OpenAPI Spec. Better to work on pulling in InferringRouter instead I think

},
)
async def get_session_commands(
session: ResponseModel[Session] = Depends(get_session),
Copy link
Contributor

@SyntaxColoring SyntaxColoring Jun 28, 2021

Choose a reason for hiding this comment

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

Off the top of my head, I do not understand this, but I'll look at it again tomorrow morning when I'm smarter.

ResponseModel[ProtocolSession],
]

SessionCommandResponse = Union[
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah. I don't know if this is the same problem you're talking about, but this is basically how I imagined we'd fix our possible links values not being specified in the OpenAPI spec.

Comment on lines +98 to +101
command_summaries = [
SessionCommandSummary(id=c.id, commandType=c.commandType, status=c.status)
for c in commands
]
Copy link
Contributor

Choose a reason for hiding this comment

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

If we basically stick with this summarization scheme, we might want something like [c.to_summary() for c in commands].

robot-server/tests/sessions/test_sessions_router.py Outdated Show resolved Hide resolved
client: TestClient,
) -> None:
"""It should return an empty collection response with no sessions exist."""
"""It should a collection response when a session exists."""
# TODO(mc, 2021-06-23): add actual multi-session support
Copy link
Contributor

Choose a reason for hiding this comment

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

This TODO seems bigger than this particular test function. Should we make it a ticket instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We don't have any user stories for this yet, so I think a TODO is appropriate

@SyntaxColoring SyntaxColoring self-requested a review June 29, 2021 02:04
Copy link
Contributor

@SyntaxColoring SyntaxColoring left a comment

Choose a reason for hiding this comment

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

[Edit: Deleted. See other comments.]

Copy link
Member

@sanni-t sanni-t left a comment

Choose a reason for hiding this comment

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

👾

@@ -106,7 +118,8 @@ async def create_session(
raise SessionAlreadyActive(detail=str(e)).as_error(status.HTTP_409_CONFLICT)

session_store.upsert(session=session)
data = session_view.as_response(session=session)
commands = engine_store.engine.state_view.commands.get_all()
Copy link
Member

@sanni-t sanni-t Jun 29, 2021

Choose a reason for hiding this comment

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

That's interesting.. How about summarization + pagination? The summarization makes it readable while pagination would help with the 'unbounded commands' problem.

@SyntaxColoring
Copy link
Contributor

SyntaxColoring commented Jun 29, 2021

Generated OpenAPI Spec makes sense?

[Edit: Tracking this problem separately in #8024.]

There's some weirdness happening to our enums. Here's a snippet from our generated OpenAPI spec:

"SessionCommandSummary": {
    "title": "SessionCommandSummary",
    "required": [
        "id",
        "commandType",
        "status"
    ],
    "type": "object",
    "properties": {
        "id": {
            "title": "Id",
            "type": "string",
            "description": "Unique command identifier."
        },
        "commandType": {
            "title": "Commandtype",
            "anyOf": [
                {
                    "const": "addLabwareDefinition",
                    "type": "string"
                },
                {
                    "const": "aspirate",
                    "type": "string"
                },
...

The anyOf consts looks good, but, in the docs...

Screen Shot 2021-06-29 at 4 17 46 PM

I don't know if this is a /redoc problem or a Pydantic problem or what.

@SyntaxColoring
Copy link
Contributor

Postman testing worked.

POSTing the start action yielded a successful response, but the robot didn't do anything. Through GET sessions/{{session_id}}/commands and GET {{session_id}}/commands/{{command_id}}, I could see that it failed on the first loadPipette command because I didn't have the correct pipettes physically attached. Cool!

@SyntaxColoring SyntaxColoring self-requested a review June 29, 2021 21:02
Copy link
Contributor

@SyntaxColoring SyntaxColoring left a comment

Choose a reason for hiding this comment

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

Looks good to me to merge, but see open comments/threads above.

Co-authored-by: Max Marrone <[email protected]>
@mcous
Copy link
Contributor Author

mcous commented Jun 29, 2021

The anyOf consts looks good, but, in the docs...

Oof. Fingers crossed this is a redoc thing. Maybe time to experiment with a FastAPI + Pydantic upgrade. Merging for now, but lets keep an close eye on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
protocol-engine Ticket related to the Protocol Engine project and associated HTTP APIs robot-svcs Falls under the purview of the Robot Services squad (formerly CPX, Core Platform Experience).
Projects
None yet
Development

Successfully merging this pull request may close these issues.

HTTP protocol: command status of engine-backed protocol session
3 participants