For the hands-on exercise, we want to decouple the data stores from the API. This is necessary to allow for quick iterations on a running application, showing zero-downtime migrations.
To decouple data stores, we introduce the data proxy, a new server component routing requests to the NoteStore interface to a data store implementation.
- The data proxy should be started by the main application as a child process.
- To simulate rolling releases, more than one data proxy may be running at a time.
- A data proxy exposes the NoteStore interface through JSON RPC.
- Each method on the NoteStore should be implemented on the data proxy, usually forwarding to a backing store like SQLite.
- Every access to a NoteStore method on the data proxy must be synchronized. This is to enforce atomicity on data access, simulating transactions and atomicity guarantees found in common data stores (SQL transactions, Redis Lua scripts, FoundationDB transactions) without having to write complex SQL.
- This is an explicit simplification for the purpose of the hands-on exercise.
- Create a new package
proxy - Create a new file
proxy/proxy.go - Implement a new
DataProxystruct. - Implement all NoteStore interface methods on the DataProxy. Simply pass through function calls to an underlying NoteStore struct field by default.
- Add a sync.Mutex to linearize all access. Every time a NoteStore interface method is called on the DataProxy, acquire a lock or wait until one can be acquired.
- Implement a
Run(ctx context.Context) errormethod on DataProxy that starts a HTTP server listening on a defined port (on the struct). The server should stop when the passed context is closed. - Implement HTTP routes for all data proxy methods. These should expect the arguments as JSON bodies, and return responses as JSON. Follow the JSON RPC format for requests and responses.
- Implement a
NewDataProxymethod, which constructs a data proxy struct and returns a pointer. This should create a new note store and save it on the struct. - Implement a
ReadyRPC method to fill in readiness details.
- Add a new option
--proxytomain.go. This should be a boolean value and determine whether to launch the data proxy. - Add another option
--proxy-portwhich will hold a port number. This is optional. - If
--proxyistrue, instead of running the regular code, construct a new data proxy using the port and invoke.Run(ctx). Ensure that signals cancel the context passed to Run.
- Create a
proxy/client.gofile. - In it, create a ProxyClient struct. This should implement all methods of the
NoteStoreinterface, sending HTTP requests on a http.Client that's part of the struct. Requests should follow the server implementation and use JSON RPC. - Create a
NewProxyClient(id int, addr string) *ProxyClientfunction that constructs a proxy client. The ID is only used for observability. The address should be the base address for making requests to the proxy.
- Create a new file
proc.goin the proxy package. - Implement a utility
freePortthat retrieves a free port on the system. - Create a
DataProxyProcessstruct that holds an ID, a child process, log capture, and proxy client. Also store a timestamp of when this process was launched. - Create a function
LaunchDataProxy(id int) *DataProxyProcess,errorthat starts a child process runninggo run . --proxy --proxy-port RANDOM_PORTin a shell, in the current working directory. UsefreePortto retrieve a random port.- Create a data proxy client to the selected port on localhost. Supply the id to the proxy client.
- In a for loop, send up to 10 ready requests using the proxy client. If successful, return the proxy client. If unsuccessful after 10 attempts and a delay of 1s each, return an error.
- Pipe logs from the proxy to the current process using a
LogCapture(./telemetry/logs.go).
- Add a
Shutdownmethod to the data proxy process that sends a SIGTERM signal. - When not in --proxy mode, in
main.golaunch the data proxy. Pass the proxy client as a note store to all subsequent API and CLI constructors.
- Create a new file
deployment_controller.goin the proxy package. - Create a new DeploymentController struct holding
- a current and an old data proxy instance. Both may be nil.
- a deployment status (enum). This should be: INITIAL, ROLLOUT_LAUNCH_NEW, ROLLOUT_WAIT, READY
- Add two methods
Current() *DataProxyProcessandPrevious() *DataProxyProcessthat return the internal struct fields. - The DeploymentController struct should implement all NoteStore methods and dynamically forward calls to
-
- the current data proxy if no previous is configured
-
- both data proxies in random fashion if both are configured.
-
- return an error if no current or previous proxy is defined.
-
- Add a
Deploymethod that follows the following rolling release process- Try acquiring a lock and fail if already locked. Deploys should not race.
- If no current data proxy is defined, launch a data proxy process as described above with version 1, store the returned DataProxyProcess pointer as current.
- If current is defined, set
previoustocurrent, start a new process with ID previous.id + 1, wait until ready, and set ascurrent. Wait for 30s before removing the previous deployment by shutting down the process and unsetting the pointer.
- A
Shutdownmethod stopping the current and previous processes, if defined (don't forget nil checks). - In
main.go, replace the previous DataProxyProcess launch with instantianting a deployment controller and running an initialDeploy.
- [ ]Pass a pointer to the deployment controller to the
CLIApp(./cli/app.go). - Add a new panel to show deployment info:
- deployment status
- current and previous deployments, if exist. Show current or previous versions and the process launch timestamp.
- [ ]Add a new hotkey
dthat invokes Deploy() in a goroutine. Refresh deployment info every second.
- [ ]Extend the
telemetry/StatsCollectorwith per-proxy stats:- proxyNoteListRequests by proxy ID
- proxyNoteReadRequests by proxy ID
- proxyNoteCreateRequests by proxy ID
- proxyNoteUpdateRequests by proxy ID
- proxyNoteDeleteRequests by proxy ID
- Pass the stats collector to each proxy client, increase the stats when the respective method is invoked.
-
[ ]Create a new struct
RequestStatsin ./telemetry/stats.go capturing the number and rate of requests per second. -
Extend the stats collector to track data store access by shard ID (in the form of
map[string]RequestStats):- noteListRequests by shard ID
- noteReadRequests by shard ID
- noteCreateRequests by shard ID
- noteUpdateRequests by shard ID
- noteDeleteRequests by shard ID
-
Add a
DataStoreStatsstruct including the fields as exposed with JSON struct tags. -
Add a CollectDataStoreStats() method on the StatsCollector, returning the
DataStoreStatsstruct. -
Implement a new ExportShardStats JSON RPC method on the proxy to export stats, returning the DataStoreStats
-
Implement the new export stats method on the proxy_client to retrieve stats
-
On the deployment controller, add a
StartInstrument()method that invokes the ExportShardStats method on the current and previous (if exists) deployment on the proxy clients. Ingest the returned stats into the local stats collector. This should run every 2s (the interval should be a constant on the deployment controller).
- Expose the new proxy access by ID metrics on the deployment pane.
- Next to each deployment, show the requests per second
- Expose the new shard access by shard ID metrics in a new pane
- For each shard ID, show the total requests and request rate.