diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index b75bbe1..011ae7c 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -5,3 +5,7 @@ - All successfully read license states exit 0. Configuration, request, decoding, and output failures exit nonzero. No token or database header is sent to `/health`, and no authentication verification is performed. Normal connection selection, timeout, TLS verification, and identity opt-out settings apply. License installation and key/file configuration remain server responsibilities. Existing `ping -o json` license pass-through is unchanged. Closes [arcli#34](https://github.com/Basekick-Labs/arcli/issues/34). + +### Fixed + +- Accept large integers that can be represented exactly as float64 in mixed numeric JSON write columns. Lossy conversions remain rejected, and integer-only columns retain int64 encoding. diff --git a/internal/client/msgpack_numeric_columns_test.go b/internal/client/msgpack_numeric_columns_test.go new file mode 100644 index 0000000..c3fe7a3 --- /dev/null +++ b/internal/client/msgpack_numeric_columns_test.go @@ -0,0 +1,76 @@ +package client + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "testing" +) + +func TestJSONToMsgPack_MixedNumericColumnExactIntegers(t *testing.T) { + for _, tc := range []struct { + value int64 + exact bool + }{ + {1 << 53, true}, + {(1 << 53) + 2, true}, + {-(1 << 53) - 2, true}, + {1 << 54, true}, + {math.MaxInt64 - 1023, true}, + {math.MinInt64, true}, + {(1 << 53) + 1, false}, + {-(1 << 53) - 1, false}, + {math.MaxInt64, false}, + {math.MinInt64 + 1, false}, + } { + for _, floatFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("%d/float-first=%t", tc.value, floatFirst), func(t *testing.T) { + values := []any{nil, json.Number(strconv.FormatInt(tc.value, 10)), json.Number("1.5")} + wantValues := []any{nil, float64(tc.value), float64(1.5)} + if floatFirst { + values[1], values[2] = values[2], values[1] + wantValues[1], wantValues[2] = wantValues[2], wantValues[1] + } + in, err := json.Marshal(map[string]any{"m": "cpu", "columns": map[string]any{"value": values}}) + if err != nil { + t.Fatal(err) + } + out, sum, err := JSONToMsgPack(bytes.NewReader(in)) + if !tc.exact { + if err == nil || !strings.Contains(err.Error(), "cannot be represented exactly") || out != nil { + t.Errorf("inexact integer: output=%x error=%v", out, err) + } + return + } + if err != nil { + t.Fatal(err) + } + want := encodeHex(t, map[string]any{"m": "cpu", "columns": map[string]any{"value": wantValues}}) + if got := hex.EncodeToString(out); got != want { + t.Errorf("MessagePack = %s, want %s", got, want) + } + if sum.Items != 1 || sum.Columnar != 1 || sum.Rows != 3 { + t.Errorf("summary = %+v", sum) + } + }) + } + } +} + +func TestJSONToMsgPack_IntegerColumnRetainsInt64(t *testing.T) { + const in = `{"m":"cpu","columns":{"value":[9007199254740993,9223372036854775807,-9223372036854775808]}}` + out, _, err := JSONToMsgPack(strings.NewReader(in)) + if err != nil { + t.Fatal(err) + } + want := encodeHex(t, map[string]any{ + "m": "cpu", "columns": map[string]any{"value": []any{int64(9007199254740993), int64(math.MaxInt64), int64(math.MinInt64)}}, + }) + if got := hex.EncodeToString(out); got != want { + t.Errorf("integer column = %s, want %s", got, want) + } +} diff --git a/internal/client/msgpackenc.go b/internal/client/msgpackenc.go index 7595fd3..a78ed71 100644 --- a/internal/client/msgpackenc.go +++ b/internal/client/msgpackenc.go @@ -395,10 +395,12 @@ func convertColumn(col string, arr []any, where string) ([]any, error) { if !isTime && hasInt && hasFloat { for i, v := range out { if iv, ok := v.(int64); ok { - if iv > 1<<53 || iv < -(1<<53) { + f := float64(iv) + // MaxInt64 rounds to 2^63; reject that before converting back. + if f >= 1<<63 || int64(f) != iv { return nil, fmt.Errorf("%s: column %q mixes integers and floats and %d cannot be represented exactly as a float", where, col, iv) } - out[i] = float64(iv) + out[i] = f } } } diff --git a/internal/commands/write_numeric_columns_test.go b/internal/commands/write_numeric_columns_test.go new file mode 100644 index 0000000..70b1059 --- /dev/null +++ b/internal/commands/write_numeric_columns_test.go @@ -0,0 +1,43 @@ +package commands + +import ( + "bytes" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWriteJSON_ExactLargeIntegerInMixedColumn(t *testing.T) { + bodies := make(chan []byte, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/write/msgpack" { + t.Errorf("path = %q", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + bodies <- body + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + writeTestConfig(t, srv.URL, "tok") + cmd := newWriteCmd() + cmd.SetIn(strings.NewReader(`{"m":"cpu","columns":{"value":[18014398509481984,1.5]}}`)) + out, _, err := execCmd(t, cmd, "--format", "json") + if err != nil { + t.Fatal(err) + } + body := <-bodies + // Both values use float64 tags; 2^54 is exactly representable. + want, err := hex.DecodeString("82a7636f6c756d6e7381a576616c756592cb4350000000000000cb3ff8000000000000a16da3637075") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(body, want) { + t.Errorf("uploaded MessagePack = %x, want %x", body, want) + } + if !strings.Contains(out, "OK: 1 measurement(s), 2 row(s)") { + t.Errorf("stdout = %q", out) + } +}