Skip to content

Commit

Permalink
feat!: stateful cloud.Services (#4299)
Browse files Browse the repository at this point in the history
Redesign the API for `cloud.Service` so it is possible to maintain state throughout the start/stop lifecycle.

The `cloud.Service` initializer now accepts an inflight closure that can optionally return another inflight closure, which will be called when the service is stopped.

Credits for API design: @skyrpex (inspired by React's [`useEffect`](https://react.dev/reference/react/useEffect#useeffect) API).

## Example

```js
new cloud.Service(inflight () => {
  log("starting...");
  return () => {
    log("stopping...");
  };
);
```

The handler is emitted into a single JavaScript entrypoint which creates a single instance of the object and calls the callbacks within the same context.

This implies that inflight state is preserved throughout the lifetime of the service. This also means that an external JavaScript module can preserve state through a global variable.

```js
new cloud.Service(inflight () => {
  let server = startServer();
  return () => {
    server.close();
  };
});
```

## Related Issues

Closes #2237
Closes #4248

## Testing

Added a bunch of Wing SDK tests which demonstrate this API. Since it's becoming increasingly hard to build these tests in TypeScript, I've migrated the tests in `service.test.ts` to Wing.

## Implementation Notes

Extract the VM sandboxing code used in the simulated cloud function into a `sandbox.ts` module with an API which supports making multiple calls into the same sandbox. This is leveraged by the simulator `Service` implementation to invoke the `onStop` handler within the same context in which `onStart` was called and preserves the state across calls.

## Checklist

- [x] Title matches [Winglang's style guide](https://www.winglang.io/contributing/start-here/pull_requests#how-are-pull-request-titles-formatted)
- [x] Description explains motivation and solution
- [x] Tests added (always)
- [x] Docs updated (only required for features)
- [x] Added `pr/e2e-full` label if this feature requires end-to-end testing

*By submitting this pull request, I confirm that my contribution is made under the terms of the [Wing Cloud Contribution License](https://github.com/winglang/wing/blob/main/CONTRIBUTION_LICENSE.md)*.

BREAKING CHANGE: the `cloud.Service` initializer API has changed. See docs for details.
  • Loading branch information
eladb authored Sep 29, 2023
1 parent fac3f72 commit 3860541
Show file tree
Hide file tree
Showing 35 changed files with 1,998 additions and 610 deletions.
282 changes: 203 additions & 79 deletions docs/docs/04-standard-library/01-cloud/service.md

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions examples/tests/sdk_tests/service/callbacks.test.w
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
bring cloud;
bring util;
bring http;

// hack: only supported in the "sim" target for now
if util.env("WING_TARGET") == "sim" {
let b = new cloud.Bucket();
let startCounter = new cloud.Counter();
let status = "status";
let started = "started";
let stopped = "stopped";

let s = new cloud.Service(inflight () => {
b.put(status, started);
startCounter.inc();

return () => {
b.put(status, stopped);
startCounter.dec();
};
}, autoStart: false);

test "does not start automatically if autoStart is false" {
assert(!b.tryGet(status)?);
}

test "start() calls onStart() idempotently" {
s.start();
assert(b.tryGet(status) == started);
assert(startCounter.peek() == 1);

s.start(); // idempotent, so start should not be called again
assert(startCounter.peek() == 1);
}

test "stop() calls onStop()" {
assert(startCounter.peek() == 0);

// we haven't started the service yet, so onStop() should not be called
s.stop();
assert(!b.tryGet(status)?);
assert(startCounter.peek() == 0);

// now we are starting..
s.start();
assert(startCounter.peek() == 1);

// and onStop will be called
s.stop();
assert(startCounter.peek() == 0);
assert(b.get(status) == stopped);
}
}
14 changes: 14 additions & 0 deletions examples/tests/sdk_tests/service/http-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const http = require("http");

exports.createServer = async function(body) {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.on("request", (_, res) => res.end(body));
server.on("error", reject);
server.on("listening", () => resolve({
address: () => server.address(),
close: () => new Promise((resolve) => server.close(resolve)),
}));
server.listen();
});
};
71 changes: 71 additions & 0 deletions examples/tests/sdk_tests/service/http-server.test.w
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
bring cloud;
bring util;
bring http;

struct Address {
port: num;
}

interface IHttpServer {
inflight address(): Address;
inflight close(): void;
}


// hack: only supported in the "sim" target for now
if util.env("WING_TARGET") == "sim" {
class MyService {
b: cloud.Bucket;
body: str;

pub s: cloud.Service;

init(body: str) {
this.b = new cloud.Bucket();
this.body = body;

this.s = new cloud.Service(inflight () => {
log("starting service");
let server = MyService.createServer(this.body);
let port = server.address().port;
log("listening on port ${port}");
this.b.put("port", "${port}");

return () => {
log("closing server...");
server.close();
};
});
}

pub inflight port(): num {
return num.fromStr(this.b.get("port"));
}

extern "./http-server.js" static inflight createServer(body: str): IHttpServer;
}

let foo = new MyService("bang bang!");

test "http server is started with the service" {
let response = http.get("http://localhost:${foo.port()}");
log(response.body ?? "");
assert(response.body ?? "" == "bang bang!");
}

test "service.stop() closes the http server" {
let before = http.get("http://localhost:${foo.port()}");
assert(before.ok);

foo.s.stop();

// now the http server is expected to be closed
let var error = false;
try {
http.get("http://localhost:${foo.port()}");
} catch {
error = true;
}
assert(error);
}
}
31 changes: 0 additions & 31 deletions examples/tests/sdk_tests/service/main.w

This file was deleted.

20 changes: 20 additions & 0 deletions examples/tests/sdk_tests/service/minimal.test.w
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
bring cloud;
bring util;

// hack: only supported in the "sim" target for now
if util.env("WING_TARGET") == "sim" {

let s = new cloud.Service(inflight () => {
log("hello, service!");

return () => {
log("stopping!");
};
});

test "start and stop" {
assert(s.started());
s.stop();
assert(!s.started());
}
}
49 changes: 49 additions & 0 deletions examples/tests/sdk_tests/service/stateful.test.w
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
bring cloud;
bring util;
bring http;

// hack: only supported in the "sim" target for now
if util.env("WING_TARGET") == "sim" {
class MyService {
b: cloud.Bucket;
body: str;

pub s: cloud.Service;

init(body: str) {
this.b = new cloud.Bucket();
this.body = body;

this.s = new cloud.Service(inflight () => {
log("starting service");
util.sleep(1s);
this.b.put("ready", "true");
let state = 456;

return () => {
log("stopping service");
log("state is: ${state}");

// make sure inflight state is presistent across onStart/onStop
assert(state == 456);
};
});
}

pub inflight access() {
// when access() is called we expect the service to have completed initialization
this.b.get("ready");
}

pub inflight port(): num {
return num.fromStr(this.b.get("port"));
}
}

let foo = new MyService("bang bang!");

// see https://github.com/winglang/wing/issues/4251
test "service is ready only after onStart finishes" {
foo.access();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ source: libs/wingc/src/lsp/completions.rs
kind: 7
documentation:
kind: markdown
value: "```wing\nclass Service\n```\n---\nA long-running service."
value: "```wing\nclass Service impl IInflightHost\n```\n---\nA long-running service."
sortText: gg|Service
- label: Topic
kind: 7
Expand Down Expand Up @@ -245,7 +245,7 @@ source: libs/wingc/src/lsp/completions.rs
kind: 22
documentation:
kind: markdown
value: "```wing\nstruct ServiceProps\n```\n---\nOptions for `Service`.\n### Fields\n- `autoStart?` — Whether the service should start automatically.\n- `onStart` — Handler to run when the service starts.\n- `onStop?` — Handler to run in order to stop the service."
value: "```wing\nstruct ServiceProps\n```\n---\nOptions for `Service`.\n### Fields\n- `autoStart?` — Whether the service should start automatically.\n- `env?` — Environment variables to pass to the function."
sortText: hh|ServiceProps
- label: TopicOnMessageProps
kind: 22
Expand Down Expand Up @@ -389,20 +389,32 @@ source: libs/wingc/src/lsp/completions.rs
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceClient\n```\n---\nInflight interface for `Service`.\n### Methods\n- `start` — Start the service.\n- `stop` — Stop the service."
value: "```wing\ninterface IServiceClient\n```\n---\nInflight interface for `Service`.\n### Methods\n- `start` — Start the service.\n- `started` — Indicates whether the service is started.\n- `stop` — Stop the service."
sortText: ii|IServiceClient
- label: IServiceOnEventClient
- label: IServiceHandler
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceOnEventClient\n```\n---\nInflight client for `IServiceOnEventHandler`.\n### Methods\n- `handle` — Function that will be called for service events."
sortText: ii|IServiceOnEventClient
- label: IServiceOnEventHandler
value: "```wing\ninterface IServiceHandler extends IResource\n```\n---\nExecuted when a `cloud.Service` is started.\n### Methods\n- `bind` — `preflight (host: IInflightHost, ops: Array<str>): void`\n- `handle` — Handler to run when the service starts.\n- `node` — `Node`"
sortText: ii|IServiceHandler
- label: IServiceHandlerClient
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceOnEventHandler extends IResource\n```\n---\nA resource with an inflight \"handle\" method that can be passed to `ServiceProps.on_start` || `ServiceProps.on_stop`.\n### Methods\n- `bind` — `preflight (host: IInflightHost, ops: Array<str>): void`\n- `handle` — Function that will be called for service events.\n- `node` — `Node`"
sortText: ii|IServiceOnEventHandler
value: "```wing\ninterface IServiceHandlerClient\n```\n---\nInflight client for `IServiceHandler`.\n### Methods\n- `handle` — Handler to run when the service starts."
sortText: ii|IServiceHandlerClient
- label: IServiceStopHandler
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceStopHandler extends IResource\n```\n---\nExecuted when a `cloud.Service` is stopped.\n### Methods\n- `bind` — `preflight (host: IInflightHost, ops: Array<str>): void`\n- `handle` — Handler to run when the service stops.\n- `node` — `Node`"
sortText: ii|IServiceStopHandler
- label: IServiceStopHandlerClient
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceStopHandlerClient\n```\n---\nInflight client for `IServiceStopHandler`.\n### Methods\n- `handle` — Handler to run when the service stops."
sortText: ii|IServiceStopHandlerClient
- label: ITopicClient
kind: 8
documentation:
Expand Down
30 changes: 21 additions & 9 deletions libs/wingc/src/lsp/snapshots/completions/namespace_middle_dot.snap
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ source: libs/wingc/src/lsp/completions.rs
kind: 7
documentation:
kind: markdown
value: "```wing\nclass Service\n```\n---\nA long-running service."
value: "```wing\nclass Service impl IInflightHost\n```\n---\nA long-running service."
sortText: gg|Service
- label: Topic
kind: 7
Expand Down Expand Up @@ -245,7 +245,7 @@ source: libs/wingc/src/lsp/completions.rs
kind: 22
documentation:
kind: markdown
value: "```wing\nstruct ServiceProps\n```\n---\nOptions for `Service`.\n### Fields\n- `autoStart?` — Whether the service should start automatically.\n- `onStart` — Handler to run when the service starts.\n- `onStop?` — Handler to run in order to stop the service."
value: "```wing\nstruct ServiceProps\n```\n---\nOptions for `Service`.\n### Fields\n- `autoStart?` — Whether the service should start automatically.\n- `env?` — Environment variables to pass to the function."
sortText: hh|ServiceProps
- label: TopicOnMessageProps
kind: 22
Expand Down Expand Up @@ -389,20 +389,32 @@ source: libs/wingc/src/lsp/completions.rs
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceClient\n```\n---\nInflight interface for `Service`.\n### Methods\n- `start` — Start the service.\n- `stop` — Stop the service."
value: "```wing\ninterface IServiceClient\n```\n---\nInflight interface for `Service`.\n### Methods\n- `start` — Start the service.\n- `started` — Indicates whether the service is started.\n- `stop` — Stop the service."
sortText: ii|IServiceClient
- label: IServiceOnEventClient
- label: IServiceHandler
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceOnEventClient\n```\n---\nInflight client for `IServiceOnEventHandler`.\n### Methods\n- `handle` — Function that will be called for service events."
sortText: ii|IServiceOnEventClient
- label: IServiceOnEventHandler
value: "```wing\ninterface IServiceHandler extends IResource\n```\n---\nExecuted when a `cloud.Service` is started.\n### Methods\n- `bind` — `preflight (host: IInflightHost, ops: Array<str>): void`\n- `handle` — Handler to run when the service starts.\n- `node` — `Node`"
sortText: ii|IServiceHandler
- label: IServiceHandlerClient
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceOnEventHandler extends IResource\n```\n---\nA resource with an inflight \"handle\" method that can be passed to `ServiceProps.on_start` || `ServiceProps.on_stop`.\n### Methods\n- `bind` — `preflight (host: IInflightHost, ops: Array<str>): void`\n- `handle` — Function that will be called for service events.\n- `node` — `Node`"
sortText: ii|IServiceOnEventHandler
value: "```wing\ninterface IServiceHandlerClient\n```\n---\nInflight client for `IServiceHandler`.\n### Methods\n- `handle` — Handler to run when the service starts."
sortText: ii|IServiceHandlerClient
- label: IServiceStopHandler
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceStopHandler extends IResource\n```\n---\nExecuted when a `cloud.Service` is stopped.\n### Methods\n- `bind` — `preflight (host: IInflightHost, ops: Array<str>): void`\n- `handle` — Handler to run when the service stops.\n- `node` — `Node`"
sortText: ii|IServiceStopHandler
- label: IServiceStopHandlerClient
kind: 8
documentation:
kind: markdown
value: "```wing\ninterface IServiceStopHandlerClient\n```\n---\nInflight client for `IServiceStopHandler`.\n### Methods\n- `handle` — Handler to run when the service stops."
sortText: ii|IServiceStopHandlerClient
- label: ITopicClient
kind: 8
documentation:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ source: libs/wingc/src/lsp/completions.rs
kind: 7
documentation:
kind: markdown
value: "```wing\nclass Service\n```\n---\nA long-running service."
value: "```wing\nclass Service impl IInflightHost\n```\n---\nA long-running service."
sortText: gg|Service
insertText: Service($0)
insertTextFormat: 2
Expand Down
Loading

0 comments on commit 3860541

Please sign in to comment.