jaystack / odata-v4-server

With JayStack OData v4 Server you can build your own data endpoints without the hassle of implementing any protocol-level code. This framework binds OData v4 requests to your annotated controller functions, and compiles OData v4 compatible response. Clients can access services through OData-compliant HTTP requests. We recommend the JayData library for consuming OData v4 APIs.
https://jaystack.com/products/jaystack-odata-v4-server/
76 stars 55 forks source link

Support cancellation of long running requests #9

Closed spinwards closed 7 years ago

spinwards commented 7 years ago

I am using odata-v4-server to pump several GB of data into MS PowerBI. Ideally, if I cancel a query in PowerBI, the query should be canceled in node.js.

I can currently do this by hacking around with the one of the private properties on the ODataProcessor that is passed into the streaming get handler in my controller.

Would it be possible to expose this event (or the entire request) to the controller GET methods so that long running streaming responses can be cancelled if the request is cancelled by the client?

Here is the hack:

        if (stream instanceof ODataProcessor) {
            let p = stream as any;
            p.context.request.on("close", () => {
                reader.close();
             });
        }

In context:

@odata.type(AwesomeBusinessObject)
@Edm.EntitySet("AwesomeBusinessObjects")
class AwesomeBusinessObjectController extends ODataController {
    @odata.GET
    async getItems( @odata.stream stream: Writable) {
        let reader = fs.createReadStream("data/table_of_awesome_business_objects_000.tsv");
        let parser = new Parser({ delimiter: "\t", rowDelimiter: "\n", columns: headers }); // csv parser
        let mapper = new AwesomeBusinessObjectMapper(); // map from csv -> odata entity

        // close the reader if the request is closed. 
        if (stream instanceof ODataProcessor) {
            let p = stream as any;
            p.context.request.on("close", () => {
                reader.close();
             });
        }

        return reader
            .pipe(parser)
            .pipe(mapper)
            .pipe(stream);
    }
}
lazarv commented 7 years ago

Hello @spinwards, you can access the context with the @odata.context decorator and you will get an object containing this:

{
  url: req.url,
  method: req.method,
  protocol: req.secure ? "https" : "http",
  host: req.headers.host,
  base: req.baseUrl,
  request: req,
  response: res
}

And you have to use the finish event of the response stream to watch for client closing (because the request is already over, you are just sending data through the response stream and the client is waiting for the response to be finished), like this:

context.response.on("finish", () => {
  reader.close();
});
spinwards commented 7 years ago

Great!

Everything seems to work, although the context object doesn't have have type annotations, so I have to mess around with runtime checks before I can use it safely. Any chance you can create a TypeScript class with explicit types?

This is my updated method:

@odata.type(AwesomeBusinessObject)
@Edm.EntitySet("AwesomeBusinessObjects")
class AwesomeBusinessObjectController extends ODataController {
    @odata.GET
    async getItems( @odata.stream stream: Writable, @odata.context context: any) {
        let reader = fs.createReadStream("data/table_of_awesome_business_objects_000.tsv");
        let parser = new Parser({ delimiter: "\t", rowDelimiter: "\n", columns: headers }); // csv parser
        let mapper = new AwesomeBusinessObjectMapper(); // map from csv -> odata entity

        // close the reader if the request is closed. 
        if ("response" in context) {
            let res = context.response as express.Response;
            res.on("close", () => {
                reader.close();
            });
        }

        return reader
            .pipe(parser)
            .pipe(mapper)
            .pipe(stream);
    }
}

Can you expand on your comment about the request and respond events? I instrumented my express app instance with the following:

app.use("/", (req, res, next) => {
    console.log("Request Type:", req.method);

    req.on("close", () => {
        console.log("Server: Request closed");
    });
    req.on("finish", () => {
        console.log("Server: Request finished");
    });
    req.on("end", () => {
        console.log("Server: Request ended");
    });

    res.on("close", () => {
        console.log("Server: Response closed");
    });
    res.on("finish", () => {
        console.log("Server: Response finished");
    });
    res.on("end", () => {
        console.log("Server: Response ended");
    });

    next();
});

I added the same logging to the controller to validate that it receives the same events. With the logging in place, I started node, then entered the path to the odata route in chrome. After I had received a few MB, I closed chrome. Here are the logs:

Initial request to the odata root. I did not kill the chrome process.

Request Type: GET
Server: Response finished
Server: Request ended

Request to the AwesomeBusinessObject controller's route. I killed the chrome process after a few minutes.

Request Type: GET
Server: Request closed
Controller: Request closed
Server: Response closed
Controller: Response closed

It looks like the "finish" event fires on the response when it is complete and the "end" event fires on the request when it completes. The "close" event fires on both the request and response when the request is terminated by the client app.

I repeated this test in the following scenarios and received the same results:

  1. chrome - X (stop loading this page) toolbar button
  2. chrome - Close window
  3. PowerBI - Cancel button
  4. PowerBI - Close window

I've changed over my code to use the response as you recommended, but it isn't clear to me why I should attach to the response instead of the request. Are there other tests that I should run to validate my code?

lazarv commented 7 years ago

I've included this interface in the latest version:

interface ODataHttpContext{
    url:string
    method:string
    protocol:"http"|"https"
    host:string
    base:string
    request:express.Request
    response:express.Response
}

You can use this interface to have correct type annotation on the context object. If you use your server only in a HTTP context then the context object will always be like this interface (it's constructed by the requestHandler of the server class). But if you use it outside the HTTP environment (for example as a message queue handler or as a library) your context object will be your custom execution context, so you will need another interface for that. You can use the server as a library like this:

ODataServer.execute( /* this is your context object: */ {
  url: '/AwesomeBusinessObjects',
  method: 'GET'
}).then((result) => {
  /* result here is an instance of ODataResult with proper status code, body, element type and content type */
  ...
});

I recommended to use the response stream for listening to the closing event because you have the request and the response stream on the server side and they are independent streams and you have the response stream in context. When you pipe into the server stream (the ODataProcessor) you actually pipe into the response stream with a transform stream in the middle. You can use the request stream for incoming data, like file uploads and such which are currently outside the context of the OData server (sending data to media edit links will be available in the future sometime, not yet planned).

I used the streaming features mainly for media streaming (media entities and stream properties) and in this case the events are a bit different than in yours. If I pipe a media stream to the response and close the browser tab after a while, I only get a response finish and a request end. But if I tested it with data streaming (on-the-fly generated data) the behaviour was the same as yours.