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

Replace custom models with msgspec #2157

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open

Replace custom models with msgspec #2157

wants to merge 25 commits into from

Conversation

jodal
Copy link
Member

@jodal jodal commented Mar 10, 2024

I would very much like to get rid of our own custom model implementation.

This is a proof of concept using the msgspec library to implement our models.
It passes all tests and linters, and it is still able to play music.

I'm not married to msgspec yet, but I think this was still worth it, as
switching to e.g. Pydantic would probably mostly require us to rewrite the
models themselves, and not all the other fixes I did.

Requirements

Memory usage

One of the primary goals of our old bespoke model implementation was to save
memory.

By switching to msgspec, our models still take the same amount of memory as
slot classes. However, we no longer cache identical models.

I have not measured the difference this makes.

Validation at deserialization

We need to validate input data, e.g. from HTTP clients. This is now done by
msgspec, whose JSON decoder is now used for all HTTP requests.

Validation at object instantiation

msgspec does not do any validation on object instantiation. This is mostly
something we do in our tests, and there are still a lot of passing tests doing
things that the type checker is not happy about.

Since the type checker points out where the problems are, I am not certain we
need runtime validation of Python object instantiation.

If we want to be really strict and crash early, where the objects are
instantiated (e.g. in extensions) and not where they are used (e.g. core), then
we should probably switch to something like Pydantic, that does validation at
object instantiation.

Debian packaging

Neither msgspec or Pydantic v2 is packed in Debian.

Pydantic v1: We could go with Pydantic v1 for now, until v2 is packaged. That
would be a tiny upgrade later in time.

Road ahead

  1. Please test this, and help find issues.

  2. Let's continue discussing exactly which library we want to use for our models. They all have pros and cons. This is not a clear choice.

@jodal jodal requested a review from kingosticks March 10, 2024 00:04
@jodal
Copy link
Member Author

jodal commented Mar 10, 2024

I see that I've broken Mopidy-Iris by removing the JSON encoder/decoder. I think I might need to reintroduce those anyway to unbreak web clients in general, as I don't think I can have msgspec decode something so generic as our core API method params, with lists and dicts of models.

@kingosticks
Copy link
Member

kingosticks commented Mar 11, 2024

The HTTP frontend seems to be sending empty responses:

DEBUG    2024-03-11 15:18:50,969 [10264:HttpServer] mopidy.http.handlers
  Received WebSocket message from 127.0.0.1: '{"method":"core.tracklist.set_consume","params":{"value":true},"jsonrpc":"2.0","id":24}'
DEBUG    2024-03-11 15:18:50,969 [10264:HttpServer] mopidy.http.handlers
  Sent WebSocket message to 127.0.0.1: b'{"jsonrpc":"2.0","id":24}'

Which mopidy.js doesn't like:

Error: Response without 'result' or 'error' received

EDIT:
Looking again now I have more time, the request my client is doing is maybe what's at fault here. Maybe this method should be a JSON-RPC notification since it doesn't return anything. In which case my request shoudn't be sending an id field. Not sure what's going wrong here, it should be using latest mopidy.js for this.

@kingosticks
Copy link
Member

The HTTP frontend seems to be sending empty responses:

DEBUG    2024-03-11 15:18:50,969 [10264:HttpServer] mopidy.http.handlers
  Received WebSocket message from 127.0.0.1: '{"method":"core.tracklist.set_consume","params":{"value":true},"jsonrpc":"2.0","id":24}'
DEBUG    2024-03-11 15:18:50,969 [10264:HttpServer] mopidy.http.handlers
  Sent WebSocket message to 127.0.0.1: b'{"jsonrpc":"2.0","id":24}'

Which mopidy.js doesn't like:

Error: Response without 'result' or 'error' received

EDIT: Looking again now I have more time, the request my client is doing is maybe what's at fault here. Maybe this method should be a JSON-RPC notification since it doesn't return anything. In which case my request shoudn't be sending an id field. Not sure what's going wrong here, it should be using latest mopidy.js for this.

Nope! That was wrong. All API methods in mopidy.js are requests, not notifications. And the old RPC handler works OK with the same request.

DEBUG    2024-03-11 22:00:54,535 [82011:HttpServer] mopidy.http.handlers
  Received WebSocket message from 127.0.0.1: '{"method":"core.tracklist.set_consume","params":{"value":true},"jsonrpc":"2.0","id":18}'
DEBUG    2024-03-11 22:00:54,535 [82011:HttpServer] mopidy.http.handlers
  Sent WebSocket message to 127.0.0.1: '{"jsonrpc": "2.0", "id": 18, "result": null}'

Seems this new handler omits a None result from the response, presumably because msgspec omit_defaults is used. But that doesn't follow the spec:

result
This member is REQUIRED on success.
This member MUST NOT exist if there was an error invoking the method.

So we probably need the response class to be more like the old version?

@jodal
Copy link
Member Author

jodal commented Mar 12, 2024

I added a test case and made sure JSON-RPC responses with None results are serialized, like we discussed in jodal#1.

@kingosticks
Copy link
Member

kingosticks commented Mar 13, 2024

As discussed on Zulip, the JSON-RPC parameter decoding doesn't work for dict type parameters. e.g.

For example, for search:

DEBUG    2024-03-13 23:50:37,464 [3220:HttpServer] mopidy.http.handlers
  Received WebSocket message from 127.0.0.1: '{"method":"core.library.search","params":{"query":{"any":["abba"]},"uris":["spotify:"]},"jsonrpc":"2.0","id":16}'
DEBUG    2024-03-13 23:50:37,464 [3220:HttpServer] mopidy.http.handlers
  Sent WebSocket message to 127.0.0.1: b'{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request","data":"Object missing required field `__model__` - at `$.params[...]`"}}'

And this response isn't handled well by mopidyjs because it's missing the id field (but that's a slightly different problem):

Unexpected response received. Message was: 
Object { jsonrpc: "2.0", id: null, error: {…} }
​    error: Object { code: -32600, message: "Invalid Request", data: "Object missing required field `__model__` - at `$.params[...]`" }
​    id: null
​    jsonrpc: "2.0"

type=Request | list[Request],
)
except msgspec.ValidationError as exc:
response = JsonRpcInvalidRequestError(data=str(exc)).get_response()
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
response = JsonRpcInvalidRequestError(data=str(exc)).get_response()
# Try and get any request ID that was present (non-batched only).
# This JSON is safe to decode.
id = msgspec.json.decode(request_json).get('id')
response = JsonRpcInvalidRequestError(data=str(exc)).get_response(id)


JsonRpcRequestId: TypeAlias = str | int | float

params: list[Param] | dict[str, Param] | None = None
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
params: list[Param] | dict[str, Param] | None = None
params: list[Param] | dict[str, Any] | None = None

Workaround for methods that have a dict parameter:

def build(
cls,
method: str,
params: list[Param] | dict[str, Param] | None = None,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
params: list[Param] | dict[str, Param] | None = None,
params: list[Param] | dict[str, Any] | None = None,

Goes with https://github.com/mopidy/mopidy/pull/2157/files#r1526655797

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants