-
Notifications
You must be signed in to change notification settings - Fork 9
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
feat(*)!: Updates wasi-messaging interface with feedback #23
base: main
Are you sure you want to change the base?
feat(*)!: Updates wasi-messaging interface with feedback #23
Conversation
This makes several updates to the messaging interface. Initially the README said that this wasn't going to support request/reply, but based on my reading of the Kafka, NATS, MQTT, and SQS APIs, this is a fairly common pattern. Another piece of evidence here is what I've seen as a wasmCloud maintainer from our users. Request/reply is one of the more common things we see with a messaging service. Please note that this doesn't _require_ the use of a reply-to topic, just exposes it for use. I also did a few other changes here. First is that I added the topic to the message. This was common across all systems and is often used by code to select the appropriate logic to perform. I also removed the format field as this didn't seem to be a common parameter across various services. We could definitely add a content-type member to this record in the future if needed, but I think much of that can be passed via the metadata field. There are other things I might suggest some changes to, but I want to think on them some more and open some issues to discuss them first Signed-off-by: Taylor Thomas <[email protected]>
This PR integrates various changes from talking to current users of messaging in the community as well as conversations among the champions Signed-off-by: Taylor Thomas <[email protected]>
c0f5316
to
ba28047
Compare
resource error { | ||
trace: static func() -> string; | ||
/// Errors that can occur when using the messaging interface. | ||
variant error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I took a first stab at error cases here. Please feel free to suggest more that might need to be included
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm only starting to understand the conceptual model of this proposal, so I just had some possibly-naive questions to start with:
Thanks for all the great feedback! Gimme until tomorrow and I should be able to make all the changes |
I also deleted the examples.md for now until we settle on the interface. It will be easier to add back in once we have some real world examples to point at Signed-off-by: Taylor Thomas <[email protected]>
Ok I've pushed up some additional changes as discussed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These changes are great. Thanks for your work, @thomastaylor312 !
Mostly LGTM, just one last note - I'd just probably not delete the examples.md
document. I think it can be a useful recource for people to see how an interface like this will be used "in the real world" at a quick glance. Do you think we could bring it back or achieve that goal in some other way?
My plan was to actually implement something with this interface and then copy over. I deleted purely because it was completely out of date. I am also fine to put something back together once people are ok with the changes. I'll do that before we merge |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall looks nice. The new request-reply interface looks really great! Left some comments for discussion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like where this is going. I have a handful of comments and questions.
@@ -1,178 +0,0 @@ | |||
# Examples |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be helpful to see some examples of usage with the changes to the interface.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, once we're in agreement with things, I am going to update this
Also removes extensions as a guest configuration option (for now) Signed-off-by: Taylor Thomas <[email protected]>
a3833c3
to
d491285
Compare
In many of the interfaces out there right now, we've moved more towards just calling these things config Signed-off-by: Taylor Thomas <[email protected]>
wit/producer.wit
Outdated
send: func(c: client, ch: channel, m: list<message>) -> result<_, error>; | ||
/// Sends a message to the given channel/topic. If the channel/topic is not empty, it will | ||
/// override the channel/topic in the message. | ||
send: func(c: client, ch: channel, m: message) -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
send: func(c: client, ch: channel, m: message) -> result<_, error>; | |
send: func(c: client, ch: channel, m: message, opts: option<send-options>) -> result<_, error>; |
record send-options {
timeout-ms: option<u32>
}
I expect the timeout to be configurable. How are you envisioning the situation around it?
Are you envisioning to add the value to the message
itself?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See this thread above for more information around ttl/timeout. Right now this only matters if you're in a request/reply
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't thinking of the ttl
message itself, but the network timeout, if you will, something around the lines of "I give you 2 seconds to tell me you have received (not process) the message" per request-based
/// Replies to the given message with the given response message. The details of which channel | ||
/// the message is sent to is up to the implementation. This allows for reply to details to be | ||
/// handled in the best way possible for the underlying messaging system. | ||
reply: func(reply-to: borrow<message>, reply: message) -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reply: func(reply-to: borrow<message>, reply: message) -> result<_, error>; | |
reply: func(reply-to: borrow<message>, reply: message, opts: option<reply-options>) -> result<_, error>; |
record reply-options {
timeout-ms: option<u32>
}
Same over here. How do we deal with the timeout config?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A timeout would only matter here if you're expecting the thing you sent the reply to respond with a confirmation. More complex patterns like what Kafka Streams and NATS JetStream provide are going to be layered on top of this. Those types of patterns can handle things like acks and double_acks. Everything in this package, with the exception of request reply (where the request should have an eventual timeout), is doing basic pub sub with no explicit guarantees around deliverability
/// - deleted, | ||
/// - sent to a dead-letter queue, or | ||
/// - kept in the queue for further processing. | ||
abandon: func() -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
abandon: func() -> result<_, error>; | |
abandon: func(abandon-msg) -> result<_, error>; |
record abandon-msg {
reason: ...
}
I don't know if it would be worth adding some reason for abandonment. I'm just generalizing my thoughts here to see what sticks.
These would be friendly messages in the Competing Consumer
model.
Maybe it is not worth having it as a core functionality; people could add it as an add-on.
Action | Description |
---|---|
Unknown | The client does not know what action to take. Let the server decide. |
Park | Park the message and do not resend. Put it on poison queue. |
Retry | Explicitly retry the message. |
Skip | Skip this message do not resend and do not put in poison queue. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AMQP structures related to this are here: http://docs.oasis-open.org/amqp/core/v1.0/os/amqp-core-messaging-v1.0-os.html#type-rejected.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do we think about having this just be a string rather than a specific state? I do like having additional context for sure, but am trying to keep this as simple as possible
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@thomastaylor312 yeah, that is why I left the type out.
ee9cc41
to
8383401
Compare
/// The maximum amount of time to wait for a response. If the timeout value is not set, then | ||
/// the request/reply operation will block until a message is received in response. | ||
timeout-ms: option<u32>, | ||
/// The number of expected replies. If the number of replies is not set, then the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@danbugs @devigned As I was adding the new request options, I thought about how we want to handle this. Currently, request
returns a list of messages. If we are returning a list of messages, then it makes sense we might want to specify (optionally) the number of replies we expect for things like scatter/gather operations. I am not totally sold on the idea, but put it in here so it was easy to grok. I think there are some options here:
- Keep support for receiving multiple responses and have this optional
expected-replies
field (more complex, but more flexible in what you can use it for) - Make the
request
method only return a single message (this is the simplest, but does limit you to more of a client-server type pattern) - Maybe something in between? I can't quite think of one, but I don't have a monopoly on thoughts around messaging 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make the request method only return a single message (this is the simplest, but does limit you to more of a client-server type pattern)
I think this would just cause people to make wrappers around the single reply mechanism for multiple-reply, so I'd avoid it.
Now, going over your other suggestion, I think the main question we should be asking is: What if the implementor doesn't really offer support for said feature? How should the implementor behave if, regardless, the field is set? And I'm not just saying this about expected-replies
, but other opts
too like timeout-ms
. Rather than having options all together, I'm debating if we wouldn't be better off w/ entirely different functions, so that an implementor can make an explicit choice about implementing them or not. What are your thoughts, @thomastaylor312 ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If people wanted to opt out of implementations, each one of those functions would have to be a separate interface, which could get messy. I also feel like that no matter where each of those functions live, it would start to get really annoying to have to find the right function for each thing you're doing, but that is more of a minor gripe to me than the former. So my initial thought is that I don't quite think that would work.
The comment about options is interesting. A timeout value is simple enough that even if a client doesn't natively support it, it is really easy to add that via code. So I feel like that is fairly easy. I could say the same about expected replies, but I can also start to see the slippery slope here. At what point do additional options cross over into "yeah that is going to require custom logic for every implementation." Anyway, if my first answer is correct in that many functions would be a bad idea (which it could not be), then the best path forward would be to use options but to add even more documentation that we must be very careful adding anything else to it
metadata: option<list<tuple<string, string>>> | ||
/// A message with a binary payload and additional information | ||
resource message { | ||
constructor(topic: topic, data: list<u8>, content-type: option<string>, metadata: option<list<tuple<string, string>>>); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are the expectations around the Message ID? Such metadata is so critical that it would be good to have one way to figure this out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is a message ID actually important to a guest here? Most of the time those are used in acks or other advanced operations. Each implementation can take the wit type message
and convert it to/assign it an ID based on implementation details IMO.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From the guest's perspective, I think it is critical for the guest
to control the identity of the message, the guest
is most likely the component aware of the domain, while the host
is at the platform level.
What the host
would do with it shouldn't be part of the spec. That is a different story. Still, guest
could find a component that provides the guarantees required around the dedupe and whatnot.
It's worth saying that the ID doesn't mean unique; it means an identifier.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For whatever is worth, "messaging.message_id" OpenTelemetry Semantic Conventions Trace exists. Just to point to the important of such information
metadata: option<list<tuple<string, string>>> | ||
/// A message with a binary payload and additional information | ||
resource message { | ||
constructor(topic: topic, data: list<u8>, content-type: option<string>, metadata: option<list<tuple<string, string>>>); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
constructor(topic: topic, data: list<u8>, content-type: option<string>, metadata: option<list<tuple<string, string>>>); | |
constructor(topic: topic, data: list<u8>, opts: option<message-options>); |
record message-options {
content-type: option<string>
metadata: option<list<tuple<string, string>>>
}
I feel it will be prudent to avoid breaking changes in the function signature
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lukewagner I'm curious on your thoughts here on how constructors should be handled. In particular:
- Is it idiomatic to have another record type you use in a constructor?
- Are there any other considerations around breaking a constructor API that we should take into account here? At this point I don't think the
@since
and@unstable
would necessarily help here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To carve out a way to add optional fields in a semver-compatible way (at least in a 0.2 timeframe before we relax subtyping), you can use a resource type with methods for each field, which is what wasi-http does with request-options
.
Also removes the channel parameter I forgot to remove in a previous commit Signed-off-by: Taylor Thomas <[email protected]>
8383401
to
3616a2c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Changes are looking good! Just a few more ideas/suggestions:
/// to resources that are not intended to be accessible to the guest. This means implementations | ||
/// should validate that the configured topics are valid topics the guest should have access to or | ||
/// enforce it via the credentials used to connect to the service. | ||
update-config: func(gc: config) -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think readability might be enhanced by renaming this function to something like set-subscriptions: func(topics: list<string>) -> result<_, error>;
(and deleting config
). Then, if we want to add other kinds of configuration in the future, we can just add other functions (which is a semver compatible change, unlike, atm, adding fields to records). Also, do we have to worry about the list of topics growing large, and then each time you add 1 topic, it's an O(n) operation? If so, there could be add-subscription
, remove-subscription
, etc. But it's also fine to wait and see if this is a problem.
/// Whenever this guest receives a message in one of the subscribed channels, the message is | ||
/// sent to this handler. The guest is responsible for matching on the channel and handling the | ||
/// message accordingly. | ||
handler: func(ms: message) -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: to be symmetric with wasi:http
, how about renaming the guest
interface to incoming-handler
, and renaming the handler
function to handle
?
/// - deleted, | ||
/// - sent to a dead-letter queue, or | ||
/// - kept in the queue for further processing. | ||
complete: func() -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Given that handler
now takes a single message, could we remove the complete
/abandon
methods from the message
resource and instead use the return value of handler
to indicate completion/abandonment? This has a few advantages:
- it avoids the cross-product of spec-questions of what happens if the message was { completed, abandoned, neither } x and the result of
handler
was {ok
,error
, trapped }, all of which otherwise need precise answers - it avoids questions of how
complete
andabandon
interact withrequest-reply
- it's more like normal success/failure control flow
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm glad you brought this up. I actually started doing it this way and then was unsure if it was a good idea. Now that it also came from someone else I feel more confident 😅
use types.{client, message, error}; | ||
|
||
/// Options for a request/reply operation | ||
record request-options { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest copying request-options
in wasi-http and making this a resource, with methods to set/get each field. This has the advantage of allowing semver-compatible field additions over time.
} | ||
|
||
/// Performs a blocking request/reply operation with an optional set of request options | ||
request: func(c: client, msg: message, opts: option<request-options>) -> result<option<list<message>>, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you need c
to be a borrow<client>
. Also, it seems like expected-replies
is a pretty essential part of how this function works (it explains the list
in the return type) so maybe it should be a proper parameter instead of being "hidden" in request-options
? It sounds like maybe it's not common to use a value other than 1, so what about improving the ergonomics of the common case with:
request: func(c: borrow<client>, m: message, opts: option<request-options>) -> result<mesage, error>;
request-list: func(c: borrow<client>, m: message, expected-replies: u32, opts: option<request-options>) -> result<list<message>, error>;
/// Replies to the given message with the given response message. The details of which channel | ||
/// the message is sent to is up to the implementation. This allows for reply to details to be | ||
/// handled in the best way possible for the underlying messaging system. | ||
reply: func(reply-to: borrow<message>, reply: message) -> result<_, error>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if instead of reply
being an imported interface, it was instead an exported interface:
interface incoming-request-reply-handler {
handle: func(msg: message) -> result<message, error>;
}
Here, the reply is just the success case of the result
. This can avoid some of the weird spec interaction questions like I was pointing out in my other comment on complete
. Thus, there could be a request-reply world that looks like:
world request-reply-messaging {
include messaging;
import outgoing-request-reply-handler;
export incoming-request-reply-handler;
}
type channel = string; | ||
/// The topic of a message. This is also called subject, channel, or group in various messaging | ||
/// systems. What this value means is up to the implementation. | ||
type topic = string; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've generally found that type definitions like this that assign a name to string
mostly just add indirection that the reader has to chase down without providing any real abstraction (e.g., it's a semver breaking change to attempt to change topic
to equal any other type, and generated bindings will fully expose the fact that topic
is actually a string). So I'd suggest deleting topic
and relying on the parameter/field names to convey topic-ness.
metadata: option<list<tuple<string, string>>> | ||
/// A message with a binary payload and additional information | ||
resource message { | ||
constructor(topic: topic, data: list<u8>, content-type: option<string>, metadata: option<list<tuple<string, string>>>); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To carve out a way to add optional fields in a semver-compatible way (at least in a 0.2 timeframe before we relax subtyping), you can use a resource type with methods for each field, which is what wasi-http does with request-options
.
This PR incorporates various points of feedback from discussion amongst the interface champions, users of Wasm projects, and discussion in the CNCF wasm WG. This smooths over some of the rough edges and adds support for request/reply paradigms. Some of the bigger changes are:
format
type in favor of an optional content type stringtopic
field to themessage
type