Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dd439812 |
+1
-4
@@ -20,10 +20,7 @@ RUN make lint
|
|||||||
# golang:1.25-alpine (2026-02-27)
|
# golang:1.25-alpine (2026-02-27)
|
||||||
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||||
|
|
||||||
# gcc and musl-dev are for make test: its race detector needs cgo, which
|
RUN apk add --no-cache git make
|
||||||
# Go turns on by itself once a C compiler is present. make build still
|
|
||||||
# sets CGO_ENABLED=0, so the binary stays static.
|
|
||||||
RUN apk add --no-cache gcc git make musl-dev
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|||||||
@@ -39,16 +39,14 @@ halves, so the root `make check` fails if either one is broken. We provide:
|
|||||||
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless
|
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless
|
||||||
one new enough for the frontend's dependencies is installed, yarn via
|
one new enough for the frontend's dependencies is installed, yarn via
|
||||||
corepack, `yarn install --frozen-lockfile`, the pinned Go unless one at least
|
corepack, `yarn install --frozen-lockfile`, the pinned Go unless one at least
|
||||||
as new as `backend/go.mod` asks for is installed, the Go modules, and gcc with
|
as new as `backend/go.mod` asks for is installed, and the Go modules), linking
|
||||||
the C library headers unless gcc is installed, for the race detector in
|
what it installs itself into `~/.local/bin`, which has to be on `PATH`. It
|
||||||
`make test`), linking what it installs itself into `~/.local/bin`, which has
|
installs no Go linter and not Docker: `make lint` runs the linter in Docker
|
||||||
to be on `PATH`. It installs no Go linter and not Docker: `make lint` runs the
|
|
||||||
linter in Docker
|
|
||||||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run `script/frontend-test`, then the backend's Go tests, each
|
- `script/test` — run `script/frontend-test`, then the backend's Go tests, both
|
||||||
under its own 30-second timeout
|
within one 30-second timeout
|
||||||
- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by
|
- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by
|
||||||
building the lint stage of `Dockerfile` without the cache
|
building the lint stage of `Dockerfile` without the cache
|
||||||
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
||||||
|
|||||||
@@ -32,17 +32,6 @@ latest run passes.
|
|||||||
write stop counting, and the part of its file written is removed. A file that
|
write stop counting, and the part of its file written is removed. A file that
|
||||||
cannot be deleted still counts until the next start; one already deleted by
|
cannot be deleted still counts until the next start; one already deleted by
|
||||||
hand counts as freed
|
hand counts as freed
|
||||||
- 2026-10-03: the Go tests run with the race detector and coverage (issue #88):
|
|
||||||
`backend/script/test` runs `go test -timeout 30s -race -cover ./...` and, if
|
|
||||||
that fails, runs it again with `-v` and fails. Go's `-timeout` bounds the
|
|
||||||
tests, not their compile; the root `script/test` no longer puts one 30-second
|
|
||||||
timeout around both halves, which a cold Go build cache could use up on
|
|
||||||
compiling alone. The race detector needs a C compiler: the builder stage of
|
|
||||||
`Dockerfile` has gcc and musl-dev, and `script/bootstrap` installs gcc, with
|
|
||||||
the C library headers on apt and apk, when gcc is missing; the binary is still
|
|
||||||
built with `CGO_ENABLED=0`. New tests cover the health check's answer, a valid
|
|
||||||
report's answer, a report file's exact contents, and the flush when the buffer
|
|
||||||
reaches 10 MiB; the handlers' `TestImport` stub is gone
|
|
||||||
- 2026-10-03: each target check times out after 80% of the refresh interval
|
- 2026-10-03: each target check times out after 80% of the refresh interval
|
||||||
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
|
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
|
||||||
round started early, after an interval change or when the recovery probe finds
|
round started early, after an interval change or when the recovery probe finds
|
||||||
|
|||||||
+5
-9
@@ -35,10 +35,7 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
|
|||||||
stamped in. The version is `VERSION` from the environment;
|
stamped in. The version is `VERSION` from the environment;
|
||||||
when that is unset or empty, it falls back to `git describe` inside a git
|
when that is unset or empty, it falls back to `git describe` inside a git
|
||||||
checkout, then to `dev`
|
checkout, then to `dev`
|
||||||
- `script/test` — run the Go tests with the race detector and coverage. Go's
|
- `script/test` — run the Go tests under a 30-second timeout
|
||||||
`-timeout 30s` bounds the tests, not their compile. If they fail, they run
|
|
||||||
again with `-v` for the details, and the script fails. The race detector needs
|
|
||||||
a C compiler
|
|
||||||
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run
|
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run
|
||||||
golangci-lint. It runs inside the golangci-lint image of the lint stage of
|
golangci-lint. It runs inside the golangci-lint image of the lint stage of
|
||||||
the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
|
the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
|
||||||
@@ -128,11 +125,10 @@ Reports are written as `reports-<timestamp>-<number>.jsonl.zst` files in
|
|||||||
`DATA_DIR`. The timestamp is in UTC to the millisecond, so the names sort by
|
`DATA_DIR`. The timestamp is in UTC to the millisecond, so the names sort by
|
||||||
time. The number starts at 1 when the server starts and goes up by one for each
|
time. The number starts at 1 when the server starts and goes up by one for each
|
||||||
file the server starts to write, so two files written in the same millisecond
|
file the server starts to write, so two files written in the same millisecond
|
||||||
still get different names. A failed write uses up its number and leaves a gap in
|
still get different names. A failed write uses up its number, leaving a gap in
|
||||||
the numbers: its file, if it was created, is removed. The file stays, counted
|
the numbers if the file could not be created and otherwise a file under that
|
||||||
toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
|
number that may be incomplete. Each file contains one JSON object per line,
|
||||||
Each file contains one JSON object per line, compressed with zstd. Files are
|
compressed with zstd. Files are created with `O_EXCL` to prevent overwrites.
|
||||||
created with `O_EXCL` to prevent overwrites.
|
|
||||||
|
|
||||||
### Report limits
|
### Report limits
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "sneak.berlin/go/netwatch/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// Compilation check — verifies the package parses
|
||||||
|
// and all imports resolve.
|
||||||
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/globals"
|
|
||||||
"sneak.berlin/go/netwatch/internal/handlers"
|
|
||||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/netwatch/internal/logger"
|
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newStartedHandlers builds Handlers with a real health check for the
|
|
||||||
// server named in g, and starts them, which records the time the
|
|
||||||
// uptime counts from.
|
|
||||||
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
log, err := logger.New(lc, logger.Params{Globals: g})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hc, err := healthcheck.New(lc,
|
|
||||||
healthcheck.Params{Globals: g, Logger: log})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("health check: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h, err := handlers.New(lc,
|
|
||||||
handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("handlers: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lc.RequireStart()
|
|
||||||
t.Cleanup(lc.RequireStop)
|
|
||||||
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandleHealthCheck checks the health check's answer: 200, a JSON
|
|
||||||
// content type, and a JSON object with exactly the fields of
|
|
||||||
// healthcheck.Response, carrying this server's name and version and
|
|
||||||
// an uptime counted from its start.
|
|
||||||
func TestHandleHealthCheck(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
g := &globals.Globals{Appname: "netwatch-server", Version: "v1.2.3"}
|
|
||||||
h := newStartedHandlers(t, g)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(),
|
|
||||||
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
|
|
||||||
|
|
||||||
h.HandleHealthCheck().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := rec.Header().Get("Content-Type")
|
|
||||||
if contentType != "application/json; charset=utf-8" {
|
|
||||||
t.Errorf("Content-Type = %q, want %q",
|
|
||||||
contentType, "application/json; charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
var body map[string]any
|
|
||||||
|
|
||||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("body not a JSON object: %v (%q)", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := []string{
|
|
||||||
"appname", "now", "status", "uptimeHuman", "uptimeSeconds", "version",
|
|
||||||
}
|
|
||||||
if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) {
|
|
||||||
t.Fatalf("fields = %v, want %v", got, fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
for field, want := range map[string]string{
|
|
||||||
"appname": g.Appname, "status": "ok", "version": g.Version,
|
|
||||||
} {
|
|
||||||
if body[field] != want {
|
|
||||||
t.Errorf("%s = %v, want %q", field, body[field], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
now, _ := body["now"].(string)
|
|
||||||
|
|
||||||
at, err := time.Parse(time.RFC3339Nano, now)
|
|
||||||
if err != nil || time.Since(at).Abs() > time.Minute {
|
|
||||||
t.Errorf("now = %q, want the current time in RFC 3339 (%v)", now, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Started just now, so the uptime is well under a minute.
|
|
||||||
human, _ := body["uptimeHuman"].(string)
|
|
||||||
|
|
||||||
uptime, err := time.ParseDuration(human)
|
|
||||||
if err != nil || uptime > time.Minute {
|
|
||||||
t.Errorf("uptimeHuman = %q, want a duration under a minute (%v)",
|
|
||||||
human, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
seconds, ok := body["uptimeSeconds"].(float64)
|
|
||||||
if !ok || seconds < 0 || seconds > time.Minute.Seconds() {
|
|
||||||
t.Errorf("uptimeSeconds = %v, want a number of seconds under a minute",
|
|
||||||
body["uptimeSeconds"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,63 +46,6 @@ func decodeStatus(t *testing.T, body []byte) string {
|
|||||||
return resp.Status
|
return resp.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleReportAcceptsValidReports checks the answer to a valid
|
|
||||||
// report, one with no hosts and one shaped as the frontend sends them:
|
|
||||||
// 200 and {"status":"ok"} as JSON.
|
|
||||||
func TestHandleReportAcceptsValidReports(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no hosts",
|
|
||||||
body: `{"clientId":"c1","geo":null,"hosts":[],` +
|
|
||||||
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "a host with a latency and an error sample",
|
|
||||||
body: `{"clientId":"c1","geo":null,"hosts":[{` +
|
|
||||||
`"name":"Example","url":"https://example.com/",` +
|
|
||||||
`"status":"error","history":[` +
|
|
||||||
`{"t":1790000000000,"latency":42,"error":null},` +
|
|
||||||
`{"t":1790000003000,"latency":null,"error":"timeout"}]}],` +
|
|
||||||
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h := newTestHandlers(stubAppender{}, io.Discard)
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequestWithContext(t.Context(),
|
|
||||||
http.MethodPost, "/api/v1/reports",
|
|
||||||
strings.NewReader(tt.body),
|
|
||||||
)
|
|
||||||
|
|
||||||
h.HandleReport().ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := rec.Header().Get("Content-Type")
|
|
||||||
if contentType != "application/json; charset=utf-8" {
|
|
||||||
t.Errorf("Content-Type = %q, want %q",
|
|
||||||
contentType, "application/json; charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := rec.Body.String(); got != "{\"status\":\"ok\"}\n" {
|
|
||||||
t.Errorf("body = %q, want %q", got, "{\"status\":\"ok\"}\n")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
|
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FlushSizeThreshold exposes the buffer size at which Append starts
|
|
||||||
// writing a report file to the external tests.
|
|
||||||
const FlushSizeThreshold = flushSizeThreshold
|
|
||||||
|
|
||||||
// Flush writes the buffered reports to a file now, as the periodic
|
// Flush writes the buffered reports to a file now, as the periodic
|
||||||
// flush does, so tests need not wait a minute for it.
|
// flush does, so tests need not wait a minute for it.
|
||||||
func (b *Buffer) Flush() error {
|
func (b *Buffer) Flush() error {
|
||||||
|
|||||||
@@ -424,18 +424,15 @@ func TestFileDeletedByHandFreesRoom(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFileBeingWrittenIsNeverDeleted holds the write of one report file
|
// TestFileBeingWrittenIsNeverDeleted holds a write open, its file
|
||||||
// open while a second write completes, then sends a report that needs
|
// created but not complete, while a report needs room. The file may
|
||||||
// room. Deleting either file would make it, and the one being written is
|
// not be deleted to make it, so the report is refused. Once the write
|
||||||
// the older, but only the complete one may be deleted. Once the first
|
// is complete, the file is deleted when room is needed.
|
||||||
// write is complete, its file is deleted when room is needed.
|
|
||||||
func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
||||||
report := map[string]string{"id": "writing"}
|
report := map[string]string{"id": "writing"}
|
||||||
|
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
// Room for two reports waiting to be written, but not for two
|
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
|
||||||
// beside a report file.
|
|
||||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*lineBytes(t, report)))
|
|
||||||
|
|
||||||
buf := startBuffer(t)
|
buf := startBuffer(t)
|
||||||
|
|
||||||
@@ -450,7 +447,7 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
|||||||
|
|
||||||
err := buf.Append(report)
|
err := buf.Append(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first report: %v", err)
|
t.Fatalf("report that fills the cap exactly: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
flushed := make(chan error)
|
flushed := make(chan error)
|
||||||
@@ -459,47 +456,32 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
|||||||
|
|
||||||
writing := <-created
|
writing := <-created
|
||||||
|
|
||||||
// Only the first write is held; the second goes through, and
|
// Errorf, not Fatalf, until the write is released, so that a
|
||||||
// so do the writes after it, the final one at stop included.
|
|
||||||
var complete string
|
|
||||||
|
|
||||||
buf.OnFileCreated(func(f *os.File) { complete = f.Name() })
|
|
||||||
|
|
||||||
// Errorf, not Fatalf, until the first write is released, so that a
|
|
||||||
// failure here does not leave it held.
|
// failure here does not leave it held.
|
||||||
err = buf.Append(report)
|
err = buf.Append(report)
|
||||||
if err != nil {
|
if !errors.Is(err, reportbuf.ErrFull) {
|
||||||
t.Errorf("second report: %v", err)
|
t.Errorf("report while the first was being written: "+
|
||||||
}
|
"error = %v, want ErrFull", err)
|
||||||
|
|
||||||
err = buf.Flush()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("flush of the second report: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = buf.Append(report)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("report that needs room: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists(t, writing) {
|
if !exists(t, writing) {
|
||||||
t.Error("report file deleted while it was being written")
|
t.Error("report file deleted while it was being written")
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists(t, complete) {
|
|
||||||
t.Error("complete report file kept, though room was needed")
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
close(release)
|
||||||
|
|
||||||
err = <-flushed
|
err = <-flushed
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("flush of the first report: %v", err)
|
t.Fatalf("flush: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Writes from here on, the final one at stop included, go
|
||||||
|
// through unheld.
|
||||||
|
buf.OnFileCreated(func(*os.File) {})
|
||||||
|
|
||||||
err = buf.Append(report)
|
err = buf.Append(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("report after the first write was complete: %v", err)
|
t.Fatalf("report after the write was complete: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists(t, writing) {
|
if exists(t, writing) {
|
||||||
@@ -622,11 +604,7 @@ func TestTwoFlushesInOneMillisecond(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := readReportFiles(dir)
|
files := readReportFiles(t, dir)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read report files: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) != flushes {
|
if len(files) != flushes {
|
||||||
t.Fatalf("%d report files after %d flushes", len(files), flushes)
|
t.Fatalf("%d report files after %d flushes", len(files), flushes)
|
||||||
}
|
}
|
||||||
@@ -639,88 +617,6 @@ func TestTwoFlushesInOneMillisecond(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestReportFileHoldsTheLinesAppended flushes three reports and reads
|
|
||||||
// their file back: it must decompress to exactly their JSON lines, in
|
|
||||||
// the order they were appended.
|
|
||||||
func TestReportFileHoldsTheLinesAppended(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("DATA_DIR", dir)
|
|
||||||
|
|
||||||
buf := startBuffer(t)
|
|
||||||
|
|
||||||
for _, id := range []int{1, 2, 3} {
|
|
||||||
err := buf.Append(map[string]int{"id": id})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("append report %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := buf.Flush()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("flush: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := readReportFiles(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read report files: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := `{"id":1}` + "\n" + `{"id":2}` + "\n" + `{"id":3}` + "\n"
|
|
||||||
if len(files) != 1 || files[0] != want {
|
|
||||||
t.Fatalf("report files = %q, want one holding %q", files, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFlushAtSizeThreshold appends reports until the buffer holds
|
|
||||||
// FlushSizeThreshold bytes. The append that gets it there must write
|
|
||||||
// them all to one report file, with no call to Flush and the periodic
|
|
||||||
// flush a minute away, and no earlier append may write one.
|
|
||||||
func TestFlushAtSizeThreshold(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("DATA_DIR", dir)
|
|
||||||
|
|
||||||
buf := startBuffer(t)
|
|
||||||
|
|
||||||
// Large reports, so the threshold takes a few hundred appends.
|
|
||||||
pad := strings.Repeat("a", 64<<10)
|
|
||||||
|
|
||||||
var appended strings.Builder
|
|
||||||
|
|
||||||
for id := 0; appended.Len() < reportbuf.FlushSizeThreshold; id++ {
|
|
||||||
report := map[string]any{"id": id, "pad": pad}
|
|
||||||
|
|
||||||
err := buf.Append(report)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("append report %d: %v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
line, err := json.Marshal(report)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal report %d: %v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
appended.Write(line)
|
|
||||||
appended.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append writes the file in the background, so wait for it.
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
|
|
||||||
for {
|
|
||||||
files, err := readReportFiles(dir)
|
|
||||||
if err == nil && len(files) == 1 && files[0] == appended.String() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("%d report files (error: %v), want one holding the "+
|
|
||||||
"%d bytes appended", len(files), err, appended.Len())
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportFilesBytes returns the total size of the report files in dir.
|
// reportFilesBytes returns the total size of the report files in dir.
|
||||||
func reportFilesBytes(t *testing.T, dir string) int64 {
|
func reportFilesBytes(t *testing.T, dir string) int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -745,19 +641,20 @@ func reportFilesBytes(t *testing.T, dir string) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readReportFiles returns the decompressed contents of each report
|
// readReportFiles returns the decompressed contents of each report
|
||||||
// file in dir. A file still being written does not decompress, so it
|
// file in dir.
|
||||||
// gives an error.
|
func readReportFiles(t *testing.T, dir string) []string {
|
||||||
func readReportFiles(dir string) ([]string, error) {
|
t.Helper()
|
||||||
|
|
||||||
files := os.DirFS(dir)
|
files := os.DirFS(dir)
|
||||||
|
|
||||||
names, err := fs.Glob(files, "reports-*.jsonl.zst")
|
names, err := fs.Glob(files, "reports-*.jsonl.zst")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list report files: %w", err)
|
t.Fatalf("list report files: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dec, err := zstd.NewReader(nil)
|
dec, err := zstd.NewReader(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create zstd decoder: %w", err)
|
t.Fatalf("create zstd decoder: %v", err)
|
||||||
}
|
}
|
||||||
defer dec.Close()
|
defer dec.Close()
|
||||||
|
|
||||||
@@ -766,18 +663,18 @@ func readReportFiles(dir string) ([]string, error) {
|
|||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
compressed, readErr := fs.ReadFile(files, name)
|
compressed, readErr := fs.ReadFile(files, name)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return nil, fmt.Errorf("read %s: %w", name, readErr)
|
t.Fatalf("read %s: %v", name, readErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, decErr := dec.DecodeAll(compressed, nil)
|
data, decErr := dec.DecodeAll(compressed, nil)
|
||||||
if decErr != nil {
|
if decErr != nil {
|
||||||
return nil, fmt.Errorf("decompress %s: %w", name, decErr)
|
t.Fatalf("decompress %s: %v", name, decErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
contents = append(contents, string(data))
|
contents = append(contents, string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
return contents, nil
|
return contents
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportFilePath returns the path in dir of a report file named as
|
// reportFilePath returns the path in dir of a report file named as
|
||||||
|
|||||||
+2
-10
@@ -1,20 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the backend test suite with the race detector and
|
# script/test: run the backend test suite.
|
||||||
# coverage. Go's own -timeout bounds the tests and not their compile,
|
|
||||||
# so a cold build cache cannot fail it. The race detector needs cgo,
|
|
||||||
# and so a C compiler. If the tests fail, they run again with -v for
|
|
||||||
# the details, and the script fails even if that run passes.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
go test -timeout 30s -race -cover ./... || {
|
timeout 30 go test ./...
|
||||||
echo "--- Rerunning with -v for details ---"
|
|
||||||
go test -timeout 30s -race -v ./...
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+3
-10
@@ -64,8 +64,7 @@ detect_pkgmgr() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# pkg_install <nix-attr> <apt-pkgs> <brew-formula> <apk-pkgs>: the apt
|
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||||
# and apk arguments may each list several packages, separated by spaces.
|
|
||||||
pkg_install() {
|
pkg_install() {
|
||||||
detect_pkgmgr
|
detect_pkgmgr
|
||||||
case "$PKGMGR" in
|
case "$PKGMGR" in
|
||||||
@@ -75,10 +74,10 @@ pkg_install() {
|
|||||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
|
||||||
APT_UPDATED=1
|
APT_UPDATED=1
|
||||||
fi
|
fi
|
||||||
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y $2
|
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
|
||||||
;;
|
;;
|
||||||
brew) brew install "$3" ;;
|
brew) brew install "$3" ;;
|
||||||
apk) apk add --no-cache $4 ;;
|
apk) apk add --no-cache "$4" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,12 +253,6 @@ main() {
|
|||||||
|
|
||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
if missing git; then pkg_install git git git git; fi
|
if missing git; then pkg_install git git git git; fi
|
||||||
# The race detector in make test needs cgo, which Go turns on only
|
|
||||||
# when it finds its C compiler, gcc on Linux. apt and apk ship the C
|
|
||||||
# library headers apart from gcc.
|
|
||||||
if missing gcc; then
|
|
||||||
pkg_install gcc "gcc libc6-dev" gcc "gcc musl-dev"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ensure_node
|
ensure_node
|
||||||
ensure_yarn
|
ensure_yarn
|
||||||
|
|||||||
+3
-6
@@ -1,17 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite for the whole repo: the frontend at
|
# script/test: run the test suite for the whole repo: the frontend at
|
||||||
# the repo root, then the Go backend in backend/. Each half has its own
|
# the repo root, then the Go backend in backend/. Both halves together
|
||||||
# 30-second limit, and there is none around both: from a cold Go build
|
# get 30 seconds; each also keeps its own limit for the Dockerfiles.
|
||||||
# cache, compiling the backend's tests with the race detector can take
|
|
||||||
# 30 seconds on its own, and Go's -timeout leaves the compile out.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
script/frontend-test
|
timeout 30 sh -c 'script/frontend-test && backend/script/test'
|
||||||
backend/script/test
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user