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

Spec: Formal Subscriptions Definition #305

Merged
merged 22 commits into from
May 17, 2017
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spec/Appendix B -- Grammar Summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ OperationDefinition :
- SelectionSet
- OperationType Name? VariableDefinitions? Directives? SelectionSet

OperationType : one of query mutation
OperationType : one of query mutation subscription

SelectionSet : { Selection+ }

Expand Down
8 changes: 5 additions & 3 deletions spec/Section 2 -- Language.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Clients use the GraphQL query language to make requests to a GraphQL service.
We refer to these request sources as documents. A document may contain
operations (queries and mutations are both operations) as well as fragments, a
operations (queries, mutations, and subscriptions) as well as fragments, a
common unit of composition allowing for query reuse.

A GraphQL document is defined as a syntactic grammar where terminal symbols are
Expand Down Expand Up @@ -193,12 +193,14 @@ OperationDefinition :
- OperationType Name? VariableDefinitions? Directives? SelectionSet
- SelectionSet

OperationType : one of `query` `mutation`
OperationType : one of `query` `mutation` `subscription`

There are two types of operations that GraphQL models:
There are three types of operations that GraphQL models:

* query - a read-only fetch.
* mutation - a write followed by a fetch.
* subscription - a long-lived request that returns data whenever a domain
event triggers.
Copy link
Collaborator

Choose a reason for hiding this comment

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

to recycle terms, how about: "a long-lived request that fetches data in response to source events."


Each operation is represented by an optional operation name and a selection set.

Expand Down
23 changes: 18 additions & 5 deletions spec/Section 3 -- Type System.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ A given GraphQL schema must itself be internally valid. This section describes
the rules for this validation process where relevant.

A GraphQL schema is represented by a root type for each kind of operation:
query and mutation; this determines the place in the type system where those
operations begin.
query, mutation, and subscription; this determines the place in the type system
where those operations begin.

All types within a GraphQL schema must have unique names. No two provided types
may have the same name. No provided type may have a name which conflicts with
Expand Down Expand Up @@ -1013,12 +1013,14 @@ must *not* be queried if either the `@skip` condition is true *or* the

## Initial types

A GraphQL schema includes types, indicating where query and mutation
operations start. This provides the initial entry points into the
A GraphQL schema includes types, indicating where query, mutation, and
subscription operations start. This provides the initial entry points into the
type system. The query type must always be provided, and is an Object
base type. The mutation type is optional; if it is null, that means
the system does not support mutations. If it is provided, it must
be an object base type.
be an object base type. Similarly, the subscription type is optional; if it is
null, the system does not support subscriptions. If it is provided, it must be
an object base type.

The fields on the query type indicate what fields are available at
the top level of a GraphQL query. For example, a basic GraphQL query
Expand All @@ -1043,3 +1045,14 @@ mutation setName {

Is valid when the type provided for the mutation starting type is not null,
and has a field named "setName" with a string argument named "name".

```graphql
subscription {
newMessage {
text
}
}
```

Is valid when the type provided for the subscription starting type is not null,
and has a field named "newMessage" and only contains a single root field.
2 changes: 2 additions & 0 deletions spec/Section 4 -- Introspection.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ type __Schema {
types: [__Type!]!
queryType: __Type!
mutationType: __Type
subscriptionType: __Type
directives: [__Directive!]!
}

Expand Down Expand Up @@ -195,6 +196,7 @@ type __Directive {
enum __DirectiveLocation {
QUERY
MUTATION
SUBSCRIPTION
FIELD
FRAGMENT_DEFINITION
FRAGMENT_SPREAD
Expand Down
61 changes: 61 additions & 0 deletions spec/Section 5 -- Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,67 @@ query getName {
}
```

### Subscription Operation Definitions

#### Single root field

**Formal Specification**

* For each subscription operation definition {subscription} in the document
* Let {rootFields} be the top level selection set on {subscription}.
* {rootFields} must be a set of one.

**Explanatory Text**

Subscription operations must have exactly one root field.
Copy link

Choose a reason for hiding this comment

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

@robzhu what is the reason for that? why can't i subscribe to more then one subscription with one call?
so far i thought this is implementation limitation..

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree with Rob's text that starting with just one root field is reasonable. A later version of the spec could support multiple fields, but that requires figuring out a lot of edge cases around errors, when subfields are re-executed, and more.

Copy link
Contributor

@OlegIlyenko OlegIlyenko May 9, 2017

Choose a reason for hiding this comment

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

I also would like to know the reason for this rule. I find that it is quite hard limitation, and in a way it goes against GraphQL principles where clients have the power to decide what it wants to get.

that requires figuring out a lot of edge cases around errors

I think it would be helpful to list and discuss these edge cases. From my experience, there are definitely things to consider when merging different event streams from different GraphQL subscription fields, but I find it quite manageable.

Copy link
Contributor

@OlegIlyenko OlegIlyenko May 9, 2017

Choose a reason for hiding this comment

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

I listed most of the points I found as I was implementing it in sangria here:

#282 (comment)

I think that it boils down to points 3.i, 5 and 6. My take on it is here: #282 (comment)

IMHO, if we need to make a trade off, I would rather disallow not-null root subscription field types than allow only a single subscription field in a query.

Copy link

Choose a reason for hiding this comment

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

@stubailo if one wants to experiment that's fine, but i don't think it should be in the official spec unless there is a reason (which is not implementation) behind it.

Copy link
Contributor

Choose a reason for hiding this comment

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

@stubailo thanks a lot for describing in on an example!

So you're sending two subscription fields in the request, but you're never going to get that entire response.

I don't see it as a problem but rather as a natural behavior. This is inherent property of event-based interaction. This is also the reason why I suggested to disallow not-null root subscription field types.

you can now only unsubscribe to the whole thing at once (maybe a benefit)

I also don't see it as a issue either. Can you describe in more detail why this behavior can be disadvantageous? (considering that you still can make 2 separate and isolated subscription queries if it suits better for the use-case at hand)

it's not clear what to do when one of them has a fatal error - do both get unsubscribed?

I feel that either behavior is fine as long as it is defined in the spec. Though in this case I would suggest draw inspiration from streaming libraries: if 2 event streams are joined/merged together in a single result stream, then an error in either of these will also case the result stream to fail. If one of event steams naturally completes (because of the exhaustion), then the result steam still continue to emit events until all of the source streams are exhausted. I think this behavior is quite intuitive and widespread.

Copy link
Contributor Author

@robzhu robzhu May 11, 2017

Choose a reason for hiding this comment

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

@OlegIlyenko @DxCx To give a little bit of context on where this came from, we discovered early on that it was better to use subscriptions for modeling granular events. For example, consider the three main subscriptions that operate on a facebook post: live comments, live likes, and typing indicators. These are individual subscriptions as opposed to a single "postLiveUpdate" subscription. Keeping these subscriptions granular on the client made natural sense. @laneyk and @dschafer may be able to add more perspective here.

Thinking this through, if we include multiple root fields like so:

subscription sub(...) {
  liveLike (...) {...}
  likeComment (...) {...}
}

So far, everyone seems to assume this subscription should publish data when either "live like" or "live comment" publishes. Is that clearly the intent of this query? What if there were a desire to trigger the publish only when both root fields have a publish payload available? How would we describe that? By limiting the selection to a single root field, we sidestep all that.

I also don't think the single-root-field-rule introduces any practical limitations to the client. In fact, it results in simpler client-side code, like so:

likeSubscription.subscribe(payload => updateLikeState(payload));

For subscriptions containing more than one root field, if we assume the "or" behavior, as @stubailo points out, you'll never have more than one event trigger at a time, so the code would end up looking like:

genericSubscription.subscribe(payload => {
  if (payload.subscriptionA) { updateA(payload.SubscriptionA); } 
  else if (payload.subscriptionB) { updateB(payload.SubscriptionB); }
  // etc.
});

Can someone help me understand a compelling use case that is served by multi-root subscription operations that would not be equally served by separate individual subscriptions?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Just to add a meta-point to this conversation:

In my view, there's nothing stopping us from working through these details and figuring out the edge and corner cases of allowing multiple subscription fields in a single request, however the choice we do have is to address those concerns now, or allow for more time to do so. In previous conversations @robzhu has had over the last few months about subscriptions, he has convinced many that this is far more complicated than we originally thought and may not have clear answers. This limitation is added mostly in a desire to expedite the addition of subscriptions to the spec, while reserving the ability to continue to work out how or if multi-field subscriptions should be allowed.

Had this limitations been omitted while also not making mention of how to address multi-field subscriptions in the spec, then we would see divergence of behavior and that could tie our hands in the future for deciding how to address these edge cases.

Copy link
Contributor

@OlegIlyenko OlegIlyenko May 12, 2017

Choose a reason for hiding this comment

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

Thanks a lot @robzhu and @leebyron for the insightful comments! I think now I got a better understanding of the issue. I was also in two minds on this. On one hand, I wanted to get a better understanding about motivation behind inclusion of this rule. But on the other hand, I don't want to delay the progress on subscriptions incision in the spec. I tend to agree that it is a good idea to disallow multiple fields for now and start a separate discussion. I think it is a discussion worth having. I am actually very glad that this point is considered in the spec since I was also quite concerned about the semantics of multiple subscriptions fields.

Can someone help me understand a compelling use case that is served by multi-root subscription operations that would not be equally served by separate individual subscriptions?

In general, I found it very valuable to have as much information from client as possible in single query. For example, the fact that a client can express its requirements for a view or particular part of the application in a single query allowed us to make very interesting optimizations which would be quite hard to do otherwise (it is quite hard to correlate seemingly independent requests/queries). So by allowing client to better express it's requirements with several subscription fields in a single query, we open a door for potential server-side optimizations.

Now that I'm equipped with new insights, I will give it another thought. This thread was definitely helpful in this respect.

Before we will introduce this rule though, I think it is important to consider the nullability of the subscription fields, as i mentioned above. It is possible to make a nullable field not-null later on in a backwards-compatible way. If we allow subscription fields to be not-null now, it might become a challenge in future to allow multiple subscription fields, if we decide to do so.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Nullability may mean two different things in this context - this is a great point we should address.

One thing it may mean is that a subscription may not exist given some inputs in a way that isn't considered an error. I think this interpretation is both not what you were referring to, and also probably confusing to think about. The schema talks about the type of the payload result - so we're talking about the types of responses. We should probably make this point in the spec to clarify.

Secondly the nullability of the responses. This is one of the concerns with multi-field subscriptions to address later. For example, should it be legal to have a subscription field streamThings: String? where it is legal for any payload in the event sequence to in fact be null? I don't see a compelling reason to explicitly disallow this - though it is an edge case.

I think handling the payloads of multi-field subscriptions will need to account for this


Valid examples:

```graphql
subscription sub {
newMessage {
body
sender
}
}
```

```graphql
fragment newMessageFields on Message {
body
sender
}

subscription sub {
newMessage {
... newMessageFields
}
}
```

Invalid:

```!graphql
subscription sub {
newMessage {
body
sender
}
disallowedSecondRootField
}
```

Introspection fields are counted. The following example is also invalid:

```!graphql
subscription sub {
newMessage {
body
sender
}
__typename
Copy link
Contributor

Choose a reason for hiding this comment

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

Not clear what we would name this one if we wanted to get rid of the comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a description above.

}
```

## Fields

Expand Down
148 changes: 145 additions & 3 deletions spec/Section 6 -- Execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,18 @@ ExecuteRequest(schema, document, operationName, variableValues, initialValue):
* Return {ExecuteQuery(operation, schema, coercedVariableValues, initialValue)}.
* Otherwise if {operation} is a mutation operation:
* Return {ExecuteMutation(operation, schema, coercedVariableValues, initialValue)}.
* Otherwise if {operation} is a subscription operation:
* Return {Subscribe(operation, schema, coercedVariableValues, initialValue)}.

GetOperation(document, operationName):

* If {operationName} is {null}:
* If {document} contains exactly one operation.
* Return the Operation contained in the {document}.
* Let {operation} be the Operation contained in the {document}.
* If {operation} is a subscription operation:
* If {operation} contains more than one root field, produce a query error.
* Return {operation}.
* Otherwise return {operation}.
Copy link
Collaborator

Choose a reason for hiding this comment

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

These new steps aren't necessary. The validation rule will check for this, and the CreateSourceEventStream already does the right thing of using the first (only) field in the set

* Otherwise produce a query error requiring {operationName}.
* Otherwise:
* Let {operation} be the Operation named {operationName} in {document}.
Expand Down Expand Up @@ -103,8 +109,10 @@ Note: This algorithm is very similar to {CoerceArgumentValues()}.
## Executing Operations

The type system, as described in the “Type System” section of the spec, must
provide a query root object type. If mutations are supported, it must also
provide a mutation root object type.
provide a query root object type. If mutations or subscriptions are supported,
it must also provide a mutation and subscription root object type, respectively.

### Query

If the operation is a query, the result of the operation is the result of
executing the query’s top level selection set with the query root object type.
Expand All @@ -123,6 +131,8 @@ ExecuteQuery(query, schema, variableValues, initialValue):
selection set.
* Return an unordered map containing {data} and {errors}.

### Mutation

If the operation is a mutation, the result of the operation is the result of
executing the mutation’s top level selection set on the mutation root
object type. This selection set should be executed serially.
Expand All @@ -143,6 +153,138 @@ ExecuteMutation(mutation, schema, variableValues, initialValue):
selection set.
* Return an unordered map containing {data} and {errors}.
Copy link
Contributor

Choose a reason for hiding this comment

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

Putting a comment here because silly GitHub won't let me comment on the lines above. Up on line 106-107 it says:

If mutations are supported, it must also provide a mutation root object type.

That should probably say:

If mutations or subscriptions are supported, it must also provide a mutation and subscription root object type, respectively.

Or words to that effect.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also noticed that a step should be added to {ExecuteRequest} above to handle subscription requests.


### Subscription
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we include some example responses here, and perhaps an example of a lifecycle for a simple subscription?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea, I've added an example after the unsubscribe section.


If the operation is a subscription, the result is an event stream called the
"Response Stream" where each event in the event stream is the result of
executing the operation for each new event on an underlying "Source Stream".

An event stream represents a sequence of discrete events over time which can be
observed. As an example, a "Pub-Sub" system may produce an event stream when
"subscribing to a topic", with an event occurring on that event stream for each
"publish" to that topic. Event streams may produce an infinite sequence of
events or may complete at any point. Event streams may complete in response to
an error or simply because no more events will occur. An observer may at any
point decide to stop observing an event stream, after which it must receive no
more events from that event stream.

As an example, consider a chat application. To subscribe to new messages posted
to the chat room, the client sends a request like so:

```graphql
subscription NewMessages {
newMessage(roomId: 123) {
sender
text
}
}
```

While the client is subscribed, whenever new messages are posted to chat room
with ID "123", the selection for "sender" and "text" will be evaluated and
published to the client, for example:

```js
{
"data": {
"newMessage": {
"sender": "Hagrid",
"text": "You're a wizard!"
}
}
}
```

The "new message posted to chat room" could use a "Pub-Sub" system where the
chat room ID is the "topic" and each "publish" contains the sender and text.

**Supporting Subscriptions at Scale**

Supporting subscriptions is a significant change for any GraphQL server. Query
and mutation operations are stateless, allowing scaling via cloning of GraphQL
server instances. Subscriptions, by contrast, are stateful and require
maintaining the GraphQL document, variables, and other context over the lifetime
of the subscription.

Consider the behavior of your system when state is lost due to the failure of a
single machine in a service. Durability and availability may be improved by
having separate dedicated services for managing subscription state and client
connectivity.

#### Subscribe

Executing a subscription creates a persistent function on the server that
maps an underlying Source stream to the Response Stream. The logic to create the
Source stream is application-specific and takes the root field and query
variables as inputs.

Subscribe(subscription, schema, variableValues, initialValue):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this be called ExecuteSubscription to mirror the other algos called by ExecuteRequest?

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'm concerned that "ExecuteSubscription" and "ExecuteSubscriptionEvent" will be confused with one another. The name seems to imply that the caller can expect a response containing data.


* Let {sourceStream} be the result of running {CreateSourceEventStream(subscription, schema, variableValues, initialValue)}.
* Let {responseStream} be the result of running {MapSourceToResponseEvent(sourceStream, subscription, schema, variableValues)}
* Return {responseStream}.

CreateSourceEventStream(subscription, schema, variableValues, initialValue):

* Let {subscriptionType} be the root Subscription type in {schema}.
* Assert: {subscriptionType} is an Object type.
* Let {selectionSet} be the top level Selection Set in {subscription}.
* Let {rootField} be the first top level field in {selectionSet}.
* Let {argumentValues} be the result of {CoerceArgumentValues(subscriptionType, rootField, variableValues)}.
* Let {fieldStream} be the result of running {ResolveFieldEventStream(subscriptionType, initialValue, rootField, argumentValues)}.
* Return {fieldStream}.

ResolveFieldEventStream(subscriptionType, rootValue, fieldName, argumentValues):
* Let {resolver} be the internal function provided by {subscriptionType} for
determining the resolved value of a field named {fieldName}.
* Return the result of calling {resolver}, providing {rootValue} and {argumentValues}.
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be nice to add a Note: below this algorithm mentioning its intentional similarity to {ResolveFieldValue}


Note: this algorithm is intentionally similar to {ResolveFieldValue} to enable
consistency when defining resolvers on any operation type.

#### Response Stream

Each event in the underlying event stream triggers execution of the subscription
selection set.

MapSourceToResponseEvent(sourceStream, subscription, schema, variableValues):

* Return a new event stream {responseStream} which yields events as follows:
* For each {event} on {sourceStream}:
* Let {response} be the result of running
{ExecuteSubscriptionEvent(subscription, schema, variableValues, event)}.
* Yield an event containing {response}.

ExecuteSubscriptionEvent(subscription, schema, variableValues, initialValue):

* Let {subscriptionType} be the root Subscription type in {schema}.
* Assert: {subscriptionType} is an Object type.
* Let {selectionSet} be the top level Selection Set in {subscription}.
* Let {data} be the result of running
{ExecuteSelectionSet(selectionSet, subscriptionType, initialValue, variableValues)}
*normally* (allowing parallelization).
* Let {errors} be any *field errors* produced while executing the
selection set.
* Return an unordered map containing {data} and {errors}.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Similarly, it would be nice to have a Note: which mentions the intentional similarity to {ExecuteQuery}


Note: in large scale subscription systems, the {ExecuteSubscriptionEvent} and
{Subscribe} algorithms may be run on separate services to maintain predictable
scaling properties. See the section above on Supporting Subscriptions at Scale.
This algorithm is intentionally similar to {ExecuteQuery} since this is where
the subscription's selection set is executed.

#### Unsubscribe

Unsubscribe cancels the Response Stream. This is also a good opportunity for the
server to clean up the underlying event stream and any other resources used by
the subscription. Here are some example cases in which to Unsubscribe: client
no longer wishes to receive payloads for a subscription; the source event stream
produced an error or naturally ended; the server encountered an error during
{ExecuteSubscriptionEvent}.

Unsubscribe()

* Cancel {responseStream}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Above we used the term "stop observing" - we should probably be consistent here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since the server is the producer of the response stream, if we use "stop observing", we probably give the impression that we are describing client behavior, whereas the previous sections clearly describe server behavior.


## Executing Selection Sets

Expand Down