Files
fn-serverless/api/datastore/internal/datastoretest/test.go
Reed Allman 56a2861748 move calls to logstore, implement s3 (#911)
* move calls to logstore, implement s3

closes #482

the basic motivation is that logs and calls will be stored with a very high
write rate, while apps and routes will be relatively infrequently updated; it
follows that we should likely split up their storage location, to back them
with appropriate storage facilities. s3 is a good candidate for ingesting
higher write rate data than a sql database, and will make it easier to manage
that data set. can read #482 for more detailed justification.

summary:

* calls api moved from datastore to logstore
* logstore used in front-end to serve calls endpoints
* agent now throws calls into logstore instead of datastore
* s3 implementation of calls api for logstore
* s3 logs key changed (nobody using / nbd?)
* removed UpdateCall api (not in use)
* moved call tests from datastore to logstore tests
* mock logstore now tested (prev. sqlite3 only)
* logstore tests run against every datastore (mysql, pg; prev. only sqlite3)
* simplify NewMock in tests

commentary:

brunt of the work is implementing the listing of calls in GetCalls for the s3
logstore implementation. the GetCalls API requires returning items in the
newest to oldest order, and the s3 api lists items in lexicographic order
based on created_at. An easy thing to do here seemed to be to reverse the
encoding of our id format to return a lexicographically descending order,
since ids are time based, reasonably encoded to be lexicographically
sortable, and de-duped (unlike created_at). This seems to work pretty well,
it's not perfect around the boundaries of to_time and from_time and a tiny
amount of results may be omitted, but to me this doesn't seem like a deal
breaker to get 6999 results instead of 7000 when trying to get calls between
3:00pm and 4:00pm Monday 3 weeks ago. Of course, without to_time and
from_time, there are no issues in listing results. We could use created at and
encode it, but it would be an additional marker for point lookup (GetCall)
since we would have to search for a created_at stamp, search for ids around
that until we find the matching one, just to do a point lookup. So, the
tradeoff here seems worth it. There is additional optimization around to_time
to seek over newer results (since we have descending order).

The other complication in GetCalls is returning a list of calls for a given
path. Since the keys to do point lookups are only app_id + call_id, and we
need listing across an app as well, this leads us to the 'marker' collection
which is sorted by app_id + path + call_id, to allow quick listing by path.
All in all, it should be pretty straightforward to follow the implementation
and I tried to be lavish with the comments, please let me know if anything
needs further clarification in the code.

The implementation itself has some glaring inefficiencies, but they're
relatively minute: json encoding is kinda lazy, but workable; s3 doesn't offer
batch retrieval, so we point look up each call one by one in get call; not
re-using buffers -- but the seeking around the keys should all be relatively
fast, not too worried about performance really and this isn't a hot path for
reads (need to make a cut point and turn this in!).

Interestingly, in testing, minio performs significantly worse than pg for
storing both logs and calls (or just logs, I tested that too). minio seems to
have really high cpu consumption, but in any event, we won't be using minio,
we'll be using a cloud object store that implements the s3 api. Anyway, mostly
a knock on using minio for high performance, not really anything to do with
this, just thought it was interesting.

I think it's safe to remove UpdateCall, admittedly this made implementing the
s3 api a lot easier. This operation may also be something we never need, it
was unused at present and was only in the cards for a previous hybrid
implementation, which we've now abandoned. If we need, we can always resurrect
from git.

Also not worried about changing the log key, we need to put a prefix on this
thing anyway, but I don't think anybody is using this anyway. in any event, it
simply means old logs won't show up through the API, but aside from nobody
using this yet, that doesn't seem a big deal breaker really -- new logs will
appear fine.

future:

TODO make logstore implementation optional for datastore, check in front-end
at runtime and offer a nil logstore that errors appropriately

TODO low hanging fruit optimizations of json encoding, re-using buffers for
download, get multiple calls at a time, id reverse encoding could be optimized
like normal encoding to not be n^2

TODO api for range removal of logs and calls

* address review comments

* push id to_time magic into id package
* add note about s3 key sizes
* fix validation check
2018-04-05 10:49:25 -07:00

508 lines
16 KiB
Go

package datastoretest
import (
"bytes"
"context"
"log"
"testing"
"github.com/fnproject/fn/api/id"
"github.com/fnproject/fn/api/models"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
func setLogBuffer() *bytes.Buffer {
var buf bytes.Buffer
buf.WriteByte('\n')
logrus.SetOutput(&buf)
gin.DefaultErrorWriter = &buf
gin.DefaultWriter = &buf
log.SetOutput(&buf)
return &buf
}
func Test(t *testing.T, dsf func(t *testing.T) models.Datastore) {
buf := setLogBuffer()
defer func() {
if t.Failed() {
t.Log(buf.String())
}
}()
testApp.SetDefaults()
testRoute.AppID = testApp.ID
ctx := context.Background()
t.Run("apps", func(t *testing.T) {
ds := dsf(t)
// Testing insert app
_, err := ds.InsertApp(ctx, nil)
if err != models.ErrDatastoreEmptyApp {
t.Fatalf("Test InsertApp(nil): expected error `%v`, but it was `%v`", models.ErrDatastoreEmptyApp, err)
}
_, err = ds.InsertApp(ctx, &models.App{})
if err != models.ErrAppsMissingName {
t.Fatalf("Test InsertApp(&{}): expected error `%v`, but it was `%v`", models.ErrAppsMissingName, err)
}
inserted, err := ds.InsertApp(ctx, testApp)
if err != nil {
t.Fatalf("Test InsertApp: error when storing new app: %s", err)
}
if !inserted.Equals(testApp) {
t.Fatalf("Test InsertApp: expected to insert:\n%v\nbut got:\n%v", testApp, inserted)
}
testApp.ID = inserted.ID
{
// Set a config var
testApp, err := ds.GetAppByID(ctx, testApp.ID)
if err != nil {
t.Fatal(err.Error())
}
testApp.Config = map[string]string{"TEST": "1"}
updated, err := ds.UpdateApp(ctx, testApp)
if err != nil {
t.Fatalf("Test UpdateApp: error when updating app: %v", err)
}
expected := &models.App{ID: testApp.ID, Name: testApp.Name, Config: map[string]string{"TEST": "1"}}
if !updated.Equals(expected) {
t.Fatalf("Test UpdateApp: expected updated `%v` but got `%v`", expected, updated)
}
// Set a different var (without clearing the existing)
another := testApp.Clone()
another.Config = map[string]string{"OTHER": "TEST"}
updated, err = ds.UpdateApp(ctx, another)
if err != nil {
t.Fatalf("Test UpdateApp: error when updating app: %v", err)
}
expected = &models.App{Name: testApp.Name, ID: testApp.ID, Config: map[string]string{"TEST": "1", "OTHER": "TEST"}}
if !updated.Equals(expected) {
t.Fatalf("Test UpdateApp: expected updated `%v` but got `%v`", expected, updated)
}
// Delete a var
dVar := testApp.Clone()
dVar.Config = map[string]string{"TEST": ""}
updated, err = ds.UpdateApp(ctx, dVar)
if err != nil {
t.Fatalf("Test UpdateApp: error when updating app: %v", err)
}
expected = &models.App{Name: testApp.Name, ID: testApp.ID, Config: map[string]string{"OTHER": "TEST"}}
if !updated.Equals(expected) {
t.Fatalf("Test UpdateApp: expected updated `%v` but got `%v`", expected, updated)
}
}
// Testing get app
_, err = ds.GetAppByID(ctx, "")
if err != models.ErrDatastoreEmptyAppID {
t.Fatalf("Test GetApp: expected error to be %v, but it was %s", models.ErrDatastoreEmptyAppID, err)
}
app, err := ds.GetAppByID(ctx, testApp.ID)
if err != nil {
t.Fatalf("Test GetApp: error: %s", err)
}
if app.Name != testApp.Name {
t.Fatalf("Test GetApp: expected `app.Name` to be `%s` but it was `%s`", app.Name, testApp.Name)
}
// Testing list apps
apps, err := ds.GetApps(ctx, &models.AppFilter{PerPage: 100})
if err != nil {
t.Fatalf("Test GetApps: unexpected error %v", err)
}
if len(apps) == 0 {
t.Fatal("Test GetApps: expected result count to be greater than 0")
}
if apps[0].Name != testApp.Name {
t.Fatalf("Test GetApps: expected `app.Name` to be `%s` but it was `%s`", app.Name, testApp.Name)
}
// test pagination stuff (ordering / limits / cursoring)
a2 := &models.App{Name: "Testa"}
a2.SetDefaults()
a3 := &models.App{Name: "Testb"}
a3.SetDefaults()
if _, err = ds.InsertApp(ctx, a2); err != nil {
t.Fatal(err)
}
if _, err = ds.InsertApp(ctx, a3); err != nil {
t.Fatal(err)
}
apps, err = ds.GetApps(ctx, &models.AppFilter{PerPage: 1})
if err != nil {
t.Fatalf("Test GetApps: error: %s", err)
}
if len(apps) != 1 {
t.Fatalf("Test GetApps: expected result count to be 1 but got %d", len(apps))
} else if apps[0].Name != testApp.Name {
t.Fatalf("Test GetApps: expected `app.Name` to be `%s` but it was `%s`", testApp.Name, apps[0].Name)
}
apps, err = ds.GetApps(ctx, &models.AppFilter{PerPage: 100, Cursor: apps[0].Name})
if err != nil {
t.Fatalf("Test GetApps: error: %s", err)
}
if len(apps) != 2 {
t.Fatalf("Test GetApps: expected result count to be 2 but got %d", len(apps))
} else if apps[0].Name != a2.Name {
t.Fatalf("Test GetApps: expected `app.Name` to be `%s` but it was `%s`", a2.Name, apps[0].Name)
} else if apps[1].Name != a3.Name {
t.Fatalf("Test GetApps: expected `app.Name` to be `%s` but it was `%s`", a3.Name, apps[1].Name)
}
a4 := &models.App{Name: "Abcdefg"}
a4.SetDefaults()
if _, err = ds.InsertApp(ctx, a4); err != nil {
t.Fatal(err)
}
apps, err = ds.GetApps(ctx, &models.AppFilter{PerPage: 100})
if err != nil {
t.Fatalf("Test GetApps: error: %s", err)
}
if len(apps) != 4 {
t.Fatalf("Test GetApps: expected result count to be 4 but got %d", len(apps))
} else if apps[0].Name != a4.Name {
t.Fatalf("Test GetApps: expected `app.Name` to be `%s` but it was `%s`", a4.Name, apps[0].Name)
}
// TODO fix up prefix stuff
//apps, err = ds.GetApps(ctx, &models.AppFilter{Name: "Tes"})
//if err != nil {
//t.Fatalf("Test GetApps(filter): unexpected error %v", err)
//}
//if len(apps) != 3 {
//t.Fatal("Test GetApps(filter): expected result count to be 3, got", len(apps))
//}
// Testing app delete
err = ds.RemoveApp(ctx, "")
if err != models.ErrDatastoreEmptyAppID {
t.Fatalf("Test RemoveApp: expected error `%v`, but it was `%v`", models.ErrDatastoreEmptyAppID, err)
}
testApp, _ := ds.GetAppByID(ctx, testApp.ID)
err = ds.RemoveApp(ctx, testApp.ID)
if err != nil {
t.Fatalf("Test RemoveApp: error: %s", err)
}
app, err = ds.GetAppByID(ctx, testApp.ID)
if err != models.ErrAppsNotFound {
t.Fatalf("Test GetApp(removed): expected error `%v`, but it was `%v`", models.ErrAppsNotFound, err)
}
if app != nil {
t.Log(err.Error())
t.Fatal("Test RemoveApp: failed to remove the app, app should be gone already")
}
// Test update inexistent app
missingApp := &models.App{
Name: testApp.Name,
Config: map[string]string{
"TEST": "1",
},
}
missingApp.SetDefaults()
_, err = ds.UpdateApp(ctx, missingApp)
if err != models.ErrAppsNotFound {
t.Fatalf("Test UpdateApp(inexistent): expected error `%v`, but it was `%v`", models.ErrAppsNotFound, err)
}
})
t.Run("routes", func(t *testing.T) {
ds := dsf(t)
// Insert app again to test routes
testApp, err := ds.InsertApp(ctx, testApp)
if err != nil && err != models.ErrAppsAlreadyExists {
t.Fatal("Test InsertRoute Prep: failed to insert app: ", err)
}
// Testing insert route
{
_, err = ds.InsertRoute(ctx, nil)
if err != models.ErrDatastoreEmptyRoute {
t.Fatalf("Test InsertRoute(nil): expected error `%v`, but it was `%v`", models.ErrDatastoreEmptyRoute, err)
}
newTestRoute := testRoute.Clone()
newTestRoute.AppID = "notreal"
_, err = ds.InsertRoute(ctx, newTestRoute)
if err != models.ErrAppsNotFound {
t.Fatalf("Test InsertRoute: expected error `%v`, but it was `%v`", models.ErrAppsNotFound, err)
}
testRoute.AppID = testApp.ID
testRoute, err = ds.InsertRoute(ctx, testRoute)
if err != nil {
t.Fatalf("Test InsertRoute: error when storing new route: %s", err)
}
_, err = ds.InsertRoute(ctx, testRoute)
if err != models.ErrRoutesAlreadyExists {
t.Fatalf("Test InsertRoute duplicated: expected error to be `%v`, but it was `%v`", models.ErrRoutesAlreadyExists, err)
}
}
// Testing get
{
_, err = ds.GetRoute(ctx, id.New().String(), "")
if err != models.ErrRoutesMissingPath {
t.Fatalf("Test GetRoute(empty route path): expected error `%v`, but it was `%v`", models.ErrRoutesMissingPath, err)
}
_, err = ds.GetRoute(ctx, "", "a")
if err != models.ErrDatastoreEmptyAppID {
t.Fatalf("Test GetRoute(empty app name): expected error `%v`, but it was `%v`", models.ErrRoutesMissingPath, err)
}
route, err := ds.GetRoute(ctx, testApp.ID, testRoute.Path)
if err != nil {
t.Fatalf("Test GetRoute: unexpected error %v", err)
}
if !route.Equals(testRoute) {
t.Fatalf("Test InsertApp: expected to insert:\n%v\nbut got:\n%v", testRoute, *route)
}
}
// Testing update
{
// Update some fields, and add 3 configs and 3 headers.
updated, err := ds.UpdateRoute(ctx, &models.Route{
AppID: testApp.ID,
Path: testRoute.Path,
Timeout: 2,
Config: map[string]string{
"FIRST": "1",
"SECOND": "2",
"THIRD": "3",
},
Headers: models.Headers{
"First": []string{"test"},
"Second": []string{"test", "test"},
"Third": []string{"test", "test2"},
},
})
if err != nil {
t.Fatalf("Test UpdateRoute: unexpected error: %v", err)
}
expected := &models.Route{
// unchanged
AppID: testApp.ID,
Path: testRoute.Path,
Image: "fnproject/fn-test-utils",
Type: "sync",
Format: "http",
IdleTimeout: testRoute.IdleTimeout,
Memory: testRoute.Memory,
CPUs: testRoute.CPUs,
// updated
Timeout: 2,
Config: map[string]string{
"FIRST": "1",
"SECOND": "2",
"THIRD": "3",
},
Headers: models.Headers{
"First": []string{"test"},
"Second": []string{"test", "test"},
"Third": []string{"test", "test2"},
},
}
if !updated.Equals(expected) {
t.Fatalf("Test UpdateRoute: expected updated `%v` but got `%v`", expected, updated)
}
// Update a config var, remove another. Add one Header, remove another.
updated, err = ds.UpdateRoute(ctx, &models.Route{
AppID: testRoute.AppID,
Path: testRoute.Path,
Config: map[string]string{
"FIRST": "first",
"SECOND": "",
"THIRD": "3",
},
Headers: models.Headers{
"First": []string{"test2"},
"Second": nil,
},
})
if err != nil {
t.Fatalf("Test UpdateRoute: unexpected error: %v", err)
}
expected = &models.Route{
// unchanged
AppID: testRoute.AppID,
Path: testRoute.Path,
Image: "fnproject/fn-test-utils",
Type: "sync",
Format: "http",
Timeout: 2,
Memory: testRoute.Memory,
CPUs: testRoute.CPUs,
IdleTimeout: testRoute.IdleTimeout,
// updated
Config: map[string]string{
"FIRST": "first",
"THIRD": "3",
},
Headers: models.Headers{
"First": []string{"test2"},
"Third": []string{"test", "test2"},
},
}
if !updated.Equals(expected) {
t.Fatalf("Test UpdateRoute: expected updated:\n`%v`\nbut got:\n`%v`", expected, updated)
}
}
// Testing list routes
routes, err := ds.GetRoutesByApp(ctx, testApp.ID, &models.RouteFilter{PerPage: 1})
if err != nil {
t.Fatalf("Test GetRoutesByApp: unexpected error %v", err)
}
if len(routes) == 0 {
t.Fatal("Test GetRoutesByApp: expected result count to be greater than 0")
}
if routes[0] == nil {
t.Fatalf("Test GetRoutes: expected non-nil route")
} else if routes[0].Path != testRoute.Path {
t.Fatalf("Test GetRoutes: expected `app.Name` to be `%s` but it was `%s`", testRoute.Path, routes[0].Path)
}
routes, err = ds.GetRoutesByApp(ctx, testApp.ID, &models.RouteFilter{Image: testRoute.Image, PerPage: 1})
if err != nil {
t.Fatalf("Test GetRoutesByApp: unexpected error %v", err)
}
if len(routes) == 0 {
t.Fatal("Test GetRoutesByApp: expected result count to be greater than 0")
}
if routes[0] == nil {
t.Fatalf("Test GetRoutesByApp: expected non-nil route")
} else if routes[0].Path != testRoute.Path {
t.Fatalf("Test GetRoutesByApp: expected `route.Path` to be `%s` but it was `%s`", testRoute.Path, routes[0].Path)
}
nre := &models.App{Name: "notreal"}
nre.SetDefaults()
routes, err = ds.GetRoutesByApp(ctx, nre.ID, &models.RouteFilter{PerPage: 1})
if err != nil {
t.Fatalf("Test GetRoutesByApp: error: %s", err)
}
if len(routes) != 0 {
t.Fatalf("Test GetRoutesByApp: expected result count to be 0 but got %d", len(routes))
}
// test pagination stuff
r2 := *testRoute
r2.AppID = testApp.ID
r3 := *testRoute
r2.AppID = testApp.ID
r2.Path = "/testa"
r3.Path = "/testb"
if _, err = ds.InsertRoute(ctx, &r2); err != nil {
t.Fatal(err)
}
if _, err = ds.InsertRoute(ctx, &r3); err != nil {
t.Fatal(err)
}
routes, err = ds.GetRoutesByApp(ctx, testApp.ID, &models.RouteFilter{PerPage: 1})
if err != nil {
t.Fatalf("Test GetRoutesByApp: error: %s", err)
}
if len(routes) != 1 {
t.Fatalf("Test GetRoutesByApp: expected result count to be 1 but got %d", len(routes))
} else if routes[0].Path != testRoute.Path {
t.Fatalf("Test GetRoutesByApp: expected `route.Path` to be `%s` but it was `%s`", testRoute.Path, routes[0].Path)
}
routes, err = ds.GetRoutesByApp(ctx, testApp.ID, &models.RouteFilter{PerPage: 2, Cursor: routes[0].Path})
if err != nil {
t.Fatalf("Test GetRoutesByApp: error: %s", err)
}
if len(routes) != 2 {
t.Fatalf("Test GetRoutesByApp: expected result count to be 2 but got %d", len(routes))
} else if routes[0].Path != r2.Path {
t.Fatalf("Test GetRoutesByApp: expected `route.Path` to be `%s` but it was `%s`", r2.Path, routes[0].Path)
} else if routes[1].Path != r3.Path {
t.Fatalf("Test GetRoutesByApp: expected `route.Path` to be `%s` but it was `%s`", r3.Path, routes[1].Path)
}
r4 := *testRoute
r4.Path = "/abcdefg" // < /test lexicographically, but not in length
if _, err = ds.InsertRoute(ctx, &r4); err != nil {
t.Fatal(err)
}
routes, err = ds.GetRoutesByApp(ctx, testApp.ID, &models.RouteFilter{PerPage: 100})
if err != nil {
t.Fatalf("Test GetRoutesByApp: error: %s", err)
}
if len(routes) != 4 {
t.Fatalf("Test GetRoutesByApp: expected result count to be 4 but got %d", len(routes))
} else if routes[0].Path != r4.Path {
t.Fatalf("Test GetRoutesByApp: expected `route.Path` to be `%s` but it was `%s`", r4.Path, routes[0].Path)
}
// TODO test weird ordering possibilities ?
// TODO test prefix filtering
// Testing route delete
err = ds.RemoveRoute(ctx, "", "")
if err != models.ErrDatastoreEmptyAppID {
t.Fatalf("Test RemoveRoute(empty app name): expected error `%v`, but it was `%v`", models.ErrDatastoreEmptyAppID, err)
}
err = ds.RemoveRoute(ctx, testApp.ID, "")
if err != models.ErrRoutesMissingPath {
t.Fatalf("Test RemoveRoute(empty route path): expected error `%v`, but it was `%v`", models.ErrRoutesMissingPath, err)
}
err = ds.RemoveRoute(ctx, testApp.ID, testRoute.Path)
if err != nil {
t.Fatalf("Test RemoveApp: unexpected error: %v", err)
}
route, err := ds.GetRoute(ctx, testApp.ID, testRoute.Path)
if err != nil && err != models.ErrRoutesNotFound {
t.Fatalf("Test GetRoute: expected error `%v`, but it was `%v`", models.ErrRoutesNotFound, err)
}
if route != nil {
t.Fatalf("Test RemoveApp: failed to remove the route: %v", route)
}
_, err = ds.UpdateRoute(ctx, &models.Route{
AppID: testApp.ID,
Path: testRoute.Path,
Image: "test",
})
if err != models.ErrRoutesNotFound {
t.Fatalf("Test UpdateRoute inexistent: expected error to be `%v`, but it was `%v`", models.ErrRoutesNotFound, err)
}
})
}
var testApp = &models.App{
Name: "Test",
}
var testRoute = &models.Route{
Path: "/test",
Image: "fnproject/fn-test-utils",
Type: "sync",
Format: "http",
Timeout: models.DefaultTimeout,
IdleTimeout: models.DefaultIdleTimeout,
Memory: models.DefaultMemory,
}