diff --git a/cmd/mo-sirius-campaign/drain.go b/cmd/mo-sirius-campaign/drain.go new file mode 100644 index 0000000000000..918a2caf5c815 --- /dev/null +++ b/cmd/mo-sirius-campaign/drain.go @@ -0,0 +1,107 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bufio" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "io" + "time" +) + +// drainResult owns every result-set path, including late errors and failed writes. +func drainResult(rows *sql.Rows, destination io.Writer, budget int64, started time.Time, cancelQuery func()) (result resultDescriptor, firstRow *float64, err error) { + defer func() { + if err != nil { + // mysql Rows.Close drains unread packets. Cancel first so a failed + // consumer can interrupt a producer blocked by result backpressure. + cancelQuery() + } + err = errors.Join(err, rows.Close()) + }() + columns, err := rows.ColumnTypes() + if err != nil { + return result, firstRow, err + } + if len(columns) == 0 || len(columns) > 1024 { + return result, firstRow, errors.New("invalid public column count") + } + schema := make([]publicColumn, len(columns)) + raw := make([]sql.RawBytes, len(columns)) + arguments := make([]any, len(columns)) + for index, column := range columns { + entry := publicColumn{Name: column.Name(), SQLType: column.DatabaseTypeName()} + entry.Nullable, entry.NullableKnown = column.Nullable() + entry.Precision, entry.Scale, entry.DecimalKnown = column.DecimalSize() + schema[index] = entry + arguments[index] = &raw[index] + } + hasher := sha256.New() + writer := bufio.NewWriterSize(io.MultiWriter(destination, hasher), 64<<10) + var rowCount uint64 + var byteCount int64 + for rows.Next() { + if firstRow == nil { + first := time.Since(started).Seconds() + firstRow = &first + } + if err = rows.Scan(arguments...); err != nil { + return result, firstRow, err + } + values := make([]cell, len(columns)) + for index := range raw { + values[index], err = encodeCell(schema[index], raw[index]) + if err != nil { + return result, firstRow, err + } + } + encoded, marshalErr := json.Marshal(values) + if marshalErr != nil { + return result, firstRow, marshalErr + } + if len(encoded) > maxRowBytes || int64(len(encoded)+1) > budget-byteCount { + return result, firstRow, errors.New("row or spool quota exceeded") + } + if _, err = writer.Write(encoded); err != nil { + return result, firstRow, err + } + if err = writer.WriteByte('\n'); err != nil { + return result, firstRow, err + } + rowCount++ + byteCount += int64(len(encoded) + 1) + } + if err = rows.Err(); err != nil { + return result, firstRow, err + } + if rows.NextResultSet() { + return result, firstRow, errors.New("unexpected additional result set") + } + if err = rows.Err(); err != nil { + return result, firstRow, err + } + if err = rows.Close(); err != nil { + return result, firstRow, err + } + if err = writer.Flush(); err != nil { + return result, firstRow, err + } + result = resultDescriptor{Schema: schema, Rows: rowCount, Bytes: byteCount, SHA256: hex.EncodeToString(hasher.Sum(nil))} + return result, firstRow, nil +} diff --git a/cmd/mo-sirius-campaign/drain_test.go b/cmd/mo-sirius-campaign/drain_test.go new file mode 100644 index 0000000000000..3cf21ec7322ac --- /dev/null +++ b/cmd/mo-sirius-campaign/drain_test.go @@ -0,0 +1,214 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "database/sql/driver" + "encoding/hex" + "errors" + "io" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func campaignRows() *sqlmock.Rows { + return sqlmock.NewRowsWithColumnDefinition( + sqlmock.NewColumn("amount").OfType("DECIMAL", "").Nullable(true).WithPrecisionAndScale(65, 8), + sqlmock.NewColumn("text").OfType("VARCHAR", "").Nullable(true), + ) +} + +func TestPublicDrainEmptyAndExactRows(t *testing.T) { + for _, count := range []int{0, 1, 2} { + t.Run(string(rune('0'+count)), func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + data := campaignRows() + if count > 0 { + data.AddRow("-340282366920938463463374607431768211457.12345678", "") + } + if count > 1 { + data.AddRow(nil, nil) + } + mock.ExpectQuery("stream").WillReturnRows(data).RowsWillBeClosed() + rows, err := db.Query("stream") + require.NoError(t, err) + var spool bytes.Buffer + result, first, err := drainResult(rows, &spool, 4<<30, time.Now(), func() {}) + require.NoError(t, err) + require.Equal(t, uint64(count), result.Rows) + require.Equal(t, int64(spool.Len()), result.Bytes) + digest := sha256.Sum256(spool.Bytes()) + require.Equal(t, hex.EncodeToString(digest[:]), result.SHA256) + require.Len(t, result.Schema, 2, "empty results retain complete metadata") + require.Equal(t, int64(65), result.Schema[0].Precision) + require.Equal(t, int64(8), result.Schema[0].Scale) + if count == 0 { + require.Nil(t, first, "empty results have no first-row observation") + } else { + require.NotNil(t, first) + require.Contains(t, spool.String(), `"value":"-34028236692093846346337460743176821145712345678"`) + require.Contains(t, spool.String(), `{"type":"varchar","value":""}`) + } + if count == 2 { + require.Equal(t, 2, strings.Count(spool.String(), `"null":true`)) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +type failedCampaignWriter struct{} + +func (failedCampaignWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +func TestPublicDrainRejectsLateErrorsAndClosesEveryFailure(t *testing.T) { + late := errors.New("late driver failure") + closed := errors.New("result close failure") + for _, scenario := range []string{"late-error", "close-error", "writer-error", "quota", "second-result", "encode-and-close-error"} { + t.Run(scenario, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + data := campaignRows().AddRow("1.00000000", "first") + writer, budget := io.Writer(io.Discard), int64(4<<30) + sets := []*sqlmock.Rows{data} + switch scenario { + case "late-error": + data.AddRow("2.00000000", "second").RowError(1, late) + case "close-error": + data.CloseError(closed) + case "writer-error": + writer = failedCampaignWriter{} + case "quota": + budget = 1 + case "second-result": + sets = append(sets, campaignRows()) + case "encode-and-close-error": + data = campaignRows().AddRow("not-a-decimal", "value").CloseError(closed) + sets = []*sqlmock.Rows{data} + } + mock.ExpectQuery("stream").WillReturnRows(sets...).RowsWillBeClosed() + rows, err := db.Query("stream") + require.NoError(t, err) + result, _, err := drainResult(rows, writer, budget, time.Now(), func() {}) + require.Error(t, err) + require.Empty(t, result.SHA256, "partial rows never become a success descriptor") + switch scenario { + case "late-error": + require.ErrorIs(t, err, late) + case "close-error", "encode-and-close-error": + require.ErrorIs(t, err, closed) + case "writer-error": + require.ErrorIs(t, err, io.ErrClosedPipe) + } + if scenario == "encode-and-close-error" { + require.Contains(t, err.Error(), "decimal requires exact digits", "cleanup preserves the original value error") + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +// This driver observes the query context at the real database/sql close +// boundary. It leaves a second row unread when the consumer fails. +type cancellationRows struct { + ctx context.Context + next int + payload string + closed atomic.Int32 + canceled atomic.Bool +} + +func (r *cancellationRows) Columns() []string { return []string{"value"} } +func (r *cancellationRows) ColumnTypeDatabaseTypeName(int) string { return "VARCHAR" } +func (r *cancellationRows) Close() error { + r.canceled.Store(r.ctx.Err() != nil) + r.closed.Add(1) + return nil +} +func (r *cancellationRows) Next(values []driver.Value) error { + if r.next == 2 { + return io.EOF + } + values[0] = r.payload + if r.payload == "" { + values[0] = strings.Repeat("x", 70<<10) + } + r.next++ + return nil +} + +type cancellationConnector struct{ rows *cancellationRows } +type cancellationDriver struct{} +type cancellationConn struct{ rows *cancellationRows } + +func (c cancellationConnector) Connect(context.Context) (driver.Conn, error) { + return cancellationConn{rows: c.rows}, nil +} +func (c cancellationConnector) Driver() driver.Driver { return cancellationDriver{} } +func (c cancellationDriver) Open(string) (driver.Conn, error) { + return nil, errors.New("fixture requires its connector") +} +func (c cancellationConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("fixture does not prepare statements") +} +func (c cancellationConn) Close() error { return nil } +func (c cancellationConn) Begin() (driver.Tx, error) { + return nil, errors.New("fixture does not begin transactions") +} +func (c cancellationConn) QueryContext(ctx context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) { + c.rows.ctx = ctx + return c.rows, nil +} + +func TestPublicDrainCancelsUnreadQueryBeforeDriverClose(t *testing.T) { + for _, scenario := range []string{"quota", "writer"} { + t.Run(scenario, func(t *testing.T) { + data := &cancellationRows{} + db := sql.OpenDB(cancellationConnector{rows: data}) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + rows, err := db.QueryContext(ctx, "stream") + require.NoError(t, err) + writer, budget := io.Writer(io.Discard), int64(1) + if scenario == "writer" { + writer, budget = failedCampaignWriter{}, 4<<30 + } + result, _, err := drainResult(rows, writer, budget, time.Now(), cancel) + require.Error(t, err) + if scenario == "writer" { + require.ErrorIs(t, err, io.ErrClosedPipe) + } else { + require.ErrorContains(t, err, "quota") + } + require.Empty(t, result.SHA256) + require.Equal(t, 1, data.next, "failure leaves a producer row unread") + require.True(t, data.canceled.Load(), "driver close must see cancellation") + require.EqualValues(t, 1, data.closed.Load()) + }) + } +} diff --git a/cmd/mo-sirius-campaign/load.go b/cmd/mo-sirius-campaign/load.go new file mode 100644 index 0000000000000..d2c7bb61925cd --- /dev/null +++ b/cmd/mo-sirius-campaign/load.go @@ -0,0 +1,264 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/binary" + "encoding/csv" + "encoding/hex" + "encoding/json" + "errors" + "io" + "math/big" + "os" + "path/filepath" + "strings" + + "github.com/go-sql-driver/mysql" +) + +type datasetTable struct { + File string `json:"file"` + SHA256 string `json:"sha256"` + Bytes int64 `json:"bytes"` + Rows uint64 `json:"row_count"` +} + +type datasetManifest struct { + Version int `json:"schema_version"` + Scale int `json:"scale_factor"` + DDL string `json:"ddl_sha256"` + Digest string `json:"dataset_sha256"` + Tables map[string]datasetTable `json:"tables"` +} + +// Commutative cryptographic row identity avoids imposing a benchmark ORDER BY +// on a provisioning scan. Count plus 256-bit sum/XOR preserve multiplicities. +type rowIdentity struct { + count uint64 + sum big.Int + xor [32]byte +} + +func (identity *rowIdentity) add(row []cell) error { + data, err := json.Marshal(row) + if err != nil { + return err + } + digest := sha256.Sum256(data) + var value big.Int + value.SetBytes(digest[:]) + identity.sum.Add(&identity.sum, &value) + identity.sum.Mod(&identity.sum, new(big.Int).Lsh(big.NewInt(1), 256)) + for index := range digest { + identity.xor[index] ^= digest[index] + } + identity.count++ + return nil +} + +func (identity *rowIdentity) digest() string { + var value [72]byte + binary.BigEndian.PutUint64(value[:8], identity.count) + identity.sum.FillBytes(value[8:40]) + copy(value[40:], identity.xor[:]) + digest := sha256.Sum256(value[:]) + return hex.EncodeToString(digest[:]) +} + +func fileDigest(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + hash := sha256.New() + if _, err = io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func loadDataset(ctx context.Context, cfg configuration, manifestPath, database string) (map[string]any, error) { + return loadDatasetWithOpener(ctx, cfg, manifestPath, database, sql.Open) +} + +func loadDatasetWithOpener(ctx context.Context, cfg configuration, manifestPath, database string, open databaseOpener) (map[string]any, error) { + if strings.ContainsAny(database, "`\x00") || database == "" { + return nil, errors.New("invalid database") + } + if err := privateFile(manifestPath); err != nil { + return nil, err + } + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, err + } + var manifest datasetManifest + if err = json.Unmarshal(data, &manifest); err != nil { + return nil, err + } + if manifest.Version != 2 || len(manifest.Tables) != 8 { + return nil, errors.New("invalid dataset manifest") + } + root := filepath.Dir(manifestPath) + ddlPath := filepath.Join(root, "schema.sql") + actualDDL, err := fileDigest(ddlPath) + if err != nil || actualDDL != manifest.DDL { + return nil, errors.New("dataset DDL identity mismatch") + } + db, err := open("mysql", cfg.DSN) + if err != nil { + return nil, err + } + defer db.Close() + db.SetMaxOpenConns(1) + connection, err := db.Conn(ctx) + if err != nil { + return nil, err + } + defer connection.Close() + if _, err = connection.ExecContext(ctx, "create database `"+database+"`"); err != nil { + return nil, err + } + if _, err = connection.ExecContext(ctx, "use `"+database+"`"); err != nil { + return nil, err + } + ddl, err := os.ReadFile(ddlPath) + if err != nil { + return nil, err + } + for _, statement := range strings.Split(string(ddl), ";") { + if strings.TrimSpace(statement) != "" { + if _, err = connection.ExecContext(ctx, statement); err != nil { + return nil, err + } + } + } + results := make(map[string]any, 8) + for _, table := range []string{"nation", "region", "part", "supplier", "partsupp", "customer", "orders", "lineitem"} { + entry, exists := manifest.Tables[table] + if !exists || filepath.Base(entry.File) != entry.File { + return nil, errors.New("invalid dataset table") + } + path := filepath.Join(root, entry.File) + digest, err := fileDigest(path) + if err != nil || digest != entry.SHA256 { + return nil, errors.New("source dataset checksum mismatch") + } + mysql.RegisterLocalFile(path) + statement := "load data local infile '" + strings.ReplaceAll(path, "'", "''") + "' into table " + table + " fields terminated by '|' enclosed by '\"' lines terminated by '\\n'" + _, err = connection.ExecContext(ctx, statement) + mysql.DeregisterLocalFile(path) + if err != nil { + return nil, err + } + identity, err := verifyDatasetTable(ctx, connection, table, path, entry.Rows) + if err != nil { + return nil, err + } + results[table] = map[string]any{"row_count": entry.Rows, "source_sha256": entry.SHA256, "loaded_data_sha256": identity} + } + return map[string]any{"schema_version": 2, "dataset_sha256": manifest.Digest, "scale_factor": manifest.Scale, "tables": results}, nil +} + +func verifyDatasetTable(ctx context.Context, connection *sql.Conn, table, sourcePath string, expectedRows uint64) (identity string, err error) { + queryCtx, cancelQuery := context.WithCancel(ctx) + defer cancelQuery() + rows, err := connection.QueryContext(queryCtx, "select * from "+table) + if err != nil { + return "", err + } + defer func() { + if err != nil { + cancelQuery() + } + err = errors.Join(err, rows.Close()) + }() + columns, err := rows.ColumnTypes() + if err != nil { + return "", err + } + schema := make([]publicColumn, len(columns)) + raw := make([]sql.RawBytes, len(columns)) + arguments := make([]any, len(columns)) + for index, column := range columns { + schema[index] = publicColumn{Name: column.Name(), SQLType: column.DatabaseTypeName()} + schema[index].Precision, schema[index].Scale, schema[index].DecimalKnown = column.DecimalSize() + arguments[index] = &raw[index] + } + var actual rowIdentity + for rows.Next() { + if err = rows.Scan(arguments...); err != nil { + return "", err + } + values := make([]cell, len(columns)) + for index := range raw { + values[index], err = encodeCell(schema[index], raw[index]) + if err != nil { + return "", err + } + } + if err = actual.add(values); err != nil { + return "", err + } + } + if err = rows.Err(); err != nil { + return "", err + } + if err = rows.Close(); err != nil { + return "", err + } + file, err := os.Open(sourcePath) + if err != nil { + return "", err + } + defer file.Close() + reader := csv.NewReader(file) + reader.Comma = '|' + reader.FieldsPerRecord = len(columns) + reader.ReuseRecord = true + var expected rowIdentity + for { + fields, readErr := reader.Read() + if readErr == io.EOF { + break + } + if readErr != nil { + return "", readErr + } + values := make([]cell, len(fields)) + for index, value := range fields { + var bytes []byte + if value != "\\N" { + bytes = []byte(value) + } + values[index], err = encodeCell(schema[index], bytes) + if err != nil { + return "", err + } + } + if err = expected.add(values); err != nil { + return "", err + } + } + if actual.count != expectedRows || expected.count != expectedRows || actual.digest() != expected.digest() { + return "", errors.New("loaded dataset semantic identity mismatch") + } + return actual.digest(), nil +} diff --git a/cmd/mo-sirius-campaign/load_test.go b/cmd/mo-sirius-campaign/load_test.go new file mode 100644 index 0000000000000..cba915dab6a7b --- /dev/null +++ b/cmd/mo-sirius-campaign/load_test.go @@ -0,0 +1,195 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +var datasetTestTables = []string{"nation", "region", "part", "supplier", "partsupp", "customer", "orders", "lineitem"} + +func datasetVerificationRows(amount string) *sqlmock.Rows { + return sqlmock.NewRowsWithColumnDefinition( + sqlmock.NewColumn("amount").OfType("DECIMAL", "").Nullable(true).WithPrecisionAndScale(6, 2), + sqlmock.NewColumn("text").OfType("VARCHAR", "").Nullable(true), + ).AddRow(amount, "quoted|text").AddRow(nil, "") +} + +func datasetFixture(t *testing.T) (string, datasetManifest) { + t.Helper() + root := t.TempDir() + ddl := filepath.Join(root, "schema.sql") + require.NoError(t, os.WriteFile(ddl, []byte("create table fixture(a decimal(6,2),b varchar(32));"), 0600)) + digest, err := fileDigest(ddl) + require.NoError(t, err) + manifest := datasetManifest{Version: 2, Scale: 1, DDL: digest, Digest: "frozen-dataset", Tables: make(map[string]datasetTable)} + for _, table := range datasetTestTables { + name := table + ".csv" + path := filepath.Join(root, name) + require.NoError(t, os.WriteFile(path, []byte("1.20|\"quoted|text\"\n\\N|\"\"\n"), 0600)) + digest, err := fileDigest(path) + require.NoError(t, err) + manifest.Tables[table] = datasetTable{File: name, SHA256: digest, Rows: 2} + } + return filepath.Join(root, "manifest.json"), manifest +} + +func writeDatasetManifest(t *testing.T, path string, manifest datasetManifest) { + t.Helper() + data, err := json.Marshal(manifest) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0600)) +} + +func TestDatasetProvisioningVerifiesEverySourceAndTypedLoadedRow(t *testing.T) { + failed := errors.New("load operation owner") + for _, scenario := range []string{"success", "source-checksum", "traversal", "load-error", "same-count-different-value"} { + t.Run(scenario, func(t *testing.T) { + path, manifest := datasetFixture(t) + last := manifest.Tables["lineitem"] + if scenario == "source-checksum" { + last.SHA256 = "wrong" + } + if scenario == "traversal" { + last.File = "../outside.csv" + } + manifest.Tables["lineitem"] = last + writeDatasetManifest(t, path, manifest) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + mock.ExpectExec("create database `tpch`").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("use `tpch`").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("create table fixture(a decimal(6,2),b varchar(32))").WillReturnResult(sqlmock.NewResult(0, 0)) + for _, table := range datasetTestTables { + if table == "lineitem" && (scenario == "source-checksum" || scenario == "traversal") { + break + } + file := filepath.Join(filepath.Dir(path), manifest.Tables[table].File) + statement := "load data local infile '" + file + "' into table " + table + " fields terminated by '|' enclosed by '\"' lines terminated by '\\n'" + load := mock.ExpectExec(statement) + if table == "lineitem" && scenario == "load-error" { + load.WillReturnError(failed) + break + } + load.WillReturnResult(sqlmock.NewResult(0, 2)) + amount := "1.20" + if table == "lineitem" && scenario == "same-count-different-value" { + amount = "2.30" + } + mock.ExpectQuery("select * from " + table).WillReturnRows(datasetVerificationRows(amount)).RowsWillBeClosed() + } + mock.ExpectClose() + result, err := loadDatasetWithOpener(t.Context(), configuration{DSN: "private"}, path, "tpch", func(driver, dsn string) (*sql.DB, error) { + require.Equal(t, "mysql", driver) + require.Equal(t, "private", dsn) + return db, nil + }) + if scenario == "success" { + require.NoError(t, err) + require.Equal(t, manifest.Digest, result["dataset_sha256"]) + tables := result["tables"].(map[string]any) + require.Len(t, tables, 8) + for _, value := range tables { + record := value.(map[string]any) + require.Equal(t, uint64(2), record["row_count"]) + require.Len(t, record["loaded_data_sha256"], 64) + } + } else { + require.Error(t, err) + require.Nil(t, result) + if scenario == "load-error" { + require.ErrorIs(t, err, failed) + } + if scenario == "same-count-different-value" { + require.ErrorContains(t, err, "semantic identity") + } + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestDatasetManifestAdmissionRejectsUntrustedInputsBeforeSQL(t *testing.T) { + for _, scenario := range []string{"database", "public-file", "malformed", "version", "table-count", "ddl"} { + t.Run(scenario, func(t *testing.T) { + path, manifest := datasetFixture(t) + database := "tpch" + switch scenario { + case "database": + database = "bad`name" + case "version": + manifest.Version = 1 + case "table-count": + delete(manifest.Tables, "lineitem") + case "ddl": + manifest.DDL = "wrong" + } + writeDatasetManifest(t, path, manifest) + if scenario == "public-file" { + require.NoError(t, os.Chmod(path, 0644)) + } + if scenario == "malformed" { + require.NoError(t, os.WriteFile(path, []byte("{"), 0600)) + } + _, err := loadDatasetWithOpener(t.Context(), configuration{}, path, database, func(string, string) (*sql.DB, error) { + t.Fatal("invalid manifest reached SQL acquisition") + return nil, nil + }) + require.Error(t, err) + }) + } +} + +func TestDatasetRowIdentityPreservesMultiplicityAndIgnoresOrder(t *testing.T) { + a, b := []cell{{Type: "varchar", Value: "a"}}, []cell{{Type: "varchar", Value: "b"}} + var first, reversed, duplicate rowIdentity + for _, row := range [][]cell{a, b} { + require.NoError(t, first.add(row)) + } + for _, row := range [][]cell{b, a} { + require.NoError(t, reversed.add(row)) + } + for _, row := range [][]cell{a, a} { + require.NoError(t, duplicate.add(row)) + } + require.Equal(t, first.digest(), reversed.digest()) + require.NotEqual(t, first.digest(), duplicate.digest()) +} + +func TestDatasetVerificationCancelsUnreadRowsOnEncodingFailure(t *testing.T) { + data := &cancellationRows{payload: "\xff"} + db := sql.OpenDB(cancellationConnector{rows: data}) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + connection, err := db.Conn(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = connection.Close() }) + _, err = verifyDatasetTable(ctx, connection, "fixture", filepath.Join(t.TempDir(), "not-read.csv"), 2) + require.ErrorContains(t, err, "UTF-8") + require.Equal(t, 1, data.next) + require.True(t, data.canceled.Load(), "failed provisioning scan cancels before driver close") + require.EqualValues(t, 1, data.closed.Load()) +} diff --git a/cmd/mo-sirius-campaign/main.go b/cmd/mo-sirius-campaign/main.go new file mode 100644 index 0000000000000..448ccd08a5fde --- /dev/null +++ b/cmd/mo-sirius-campaign/main.go @@ -0,0 +1,348 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// mo-sirius-campaign is the public MySQL consumer for parity protocol v2. +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + "unicode/utf8" + + "github.com/go-sql-driver/mysql" +) + +const maxRowBytes = 1 << 20 + +type runSpec struct { + Sequence int `json:"sequence"` + Route string `json:"route"` + Query int `json:"query"` + QuerySHA256 string `json:"query_sha256"` + ScaleFactor int `json:"scale_factor"` + DatasetSHA256 string `json:"dataset_sha256"` + Streams int `json:"streams"` + SpoolBudget int64 `json:"spool_budget_bytes"` +} + +type configuration struct { + DSN string `json:"dsn"` + QueryDirectory string `json:"query_directory"` + Scratch string `json:"scratch"` + Databases map[string]string `json:"databases"` + Session []string `json:"session"` + TimeoutSeconds int `json:"timeout_seconds"` +} + +type publicColumn struct { + Name string `json:"name"` + SQLType string `json:"sql_type"` + Nullable bool `json:"nullable"` + NullableKnown bool `json:"nullable_known"` + Precision int64 `json:"precision"` + Scale int64 `json:"scale"` + DecimalKnown bool `json:"decimal_known"` +} + +type cell struct { + Type string `json:"type"` + Value string `json:"value"` + Scale int64 `json:"scale,omitempty"` + Null bool `json:"null,omitempty"` +} + +type resultDescriptor struct { + Schema []publicColumn `json:"public_schema"` + Rows uint64 `json:"row_count"` + Spool string `json:"spool"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +type output struct { + SchemaVersion int `json:"schema_version"` + Sequence int `json:"sequence"` + Route string `json:"route"` + QueryID string `json:"query_id"` + WallSeconds float64 `json:"wall_seconds"` + FirstRow *float64 `json:"first_row_seconds"` + Result resultDescriptor `json:"result"` +} + +func privateFile(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > maxRowBytes { + return errors.New("configuration is not a bounded private regular file") + } + return nil +} + +func readConfiguration(path string) (configuration, error) { + var cfg configuration + if err := privateFile(path); err != nil { + return cfg, err + } + data, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + err = json.Unmarshal(data, &cfg) + if err == nil && (cfg.DSN == "" || cfg.Scratch == "" || cfg.QueryDirectory == "" || cfg.TimeoutSeconds <= 0) { + err = errors.New("incomplete campaign configuration") + } + return cfg, err +} + +func exactDecimal(value string, precision, scale int64) (string, error) { + if precision < 1 || precision > 76 || scale < 0 || scale > precision || len(value) > 160 { + return "", errors.New("invalid decimal descriptor or value") + } + negative := strings.HasPrefix(value, "-") + value = strings.TrimPrefix(strings.TrimPrefix(value, "-"), "+") + parts := strings.Split(value, ".") + if len(parts) > 2 || parts[0] == "" { + return "", errors.New("invalid decimal syntax") + } + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + } + if int64(len(fraction)) > scale { + return "", errors.New("decimal scale mismatch") + } + digits := parts[0] + fraction + strings.Repeat("0", int(scale)-len(fraction)) + for _, digit := range digits { + if digit < '0' || digit > '9' { + return "", errors.New("decimal requires exact digits") + } + } + digits = strings.TrimLeft(digits, "0") + if digits == "" { + digits = "0" + } + if int64(len(digits)) > precision { + return "", errors.New("decimal precision mismatch") + } + if negative && digits != "0" { + digits = "-" + digits + } + coefficient, ok := new(big.Int).SetString(digits, 10) + if !ok { + return "", errors.New("invalid decimal coefficient") + } + return coefficient.String(), nil +} + +func encodeCell(column publicColumn, raw []byte) (cell, error) { + if len(raw) > maxRowBytes { + return cell{}, errors.New("public cell exceeds row bound") + } + typ := strings.ToUpper(column.SQLType) + result := cell{Type: strings.ToLower(typ), Null: raw == nil} + if raw == nil { + return result, nil + } + value := string(raw) + switch { + case strings.Contains(typ, "DECIMAL") || typ == "NEWDECIMAL": + if !column.DecimalKnown { + return cell{}, errors.New("decimal metadata unavailable") + } + coefficient, err := exactDecimal(value, column.Precision, column.Scale) + result.Type, result.Value, result.Scale = "decimal", coefficient, column.Scale + return result, err + case strings.Contains(typ, "INT") || typ == "YEAR": + if strings.Contains(typ, "UNSIGNED") { + parsed, err := strconv.ParseUint(value, 10, 64) + result.Type, result.Value = "uint", strconv.FormatUint(parsed, 10) + return result, err + } + parsed, err := strconv.ParseInt(value, 10, 64) + result.Type, result.Value = "int", strconv.FormatInt(parsed, 10) + return result, err + case typ == "FLOAT" || typ == "DOUBLE": + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return cell{}, errors.New("nonfinite or invalid approximate value") + } + result.Type, result.Value = "float", strconv.FormatFloat(parsed, 'g', -1, 64) + case typ == "BIT" || strings.Contains(typ, "BLOB") || strings.Contains(typ, "BINARY"): + result.Type, result.Value = "binary", base64.StdEncoding.EncodeToString(raw) + default: + if !utf8.Valid(raw) { + return cell{}, errors.New("invalid UTF-8 public string") + } + result.Value = value + } + return result, nil +} + +type databaseOpener func(string, string) (*sql.DB, error) + +func run(ctx context.Context, cfg configuration, spec runSpec) (output, error) { + return runWithOpener(ctx, cfg, spec, sql.Open) +} + +func runWithOpener(ctx context.Context, cfg configuration, spec runSpec, open databaseOpener) (out output, err error) { + if spec.Sequence < 1 || spec.Query < 1 || spec.Query > 22 || spec.SpoolBudget < 1 || spec.SpoolBudget > 4<<30 || (spec.Route != "mo-native" && spec.Route != "embedded-mo") { + return out, errors.New("invalid run specification") + } + sqlBytes, err := os.ReadFile(filepath.Join(cfg.QueryDirectory, fmt.Sprintf("q%d.sql", spec.Query))) + if err != nil { + return out, err + } + digest := sha256.Sum256(sqlBytes) + if hex.EncodeToString(digest[:]) != spec.QuerySHA256 { + return out, errors.New("canonical SQL digest mismatch") + } + database := cfg.Databases[strconv.Itoa(spec.ScaleFactor)] + if database == "" || strings.ContainsAny(database, "`\x00") { + return out, errors.New("invalid dataset database") + } + driverCfg, err := mysql.ParseDSN(cfg.DSN) + if err != nil { + return out, err + } + if driverCfg.MultiStatements || driverCfg.AllowAllFiles || driverCfg.ParseTime { + return out, errors.New("unsafe campaign driver options") + } + db, err := open("mysql", cfg.DSN) + if err != nil { + return out, err + } + defer db.Close() + db.SetMaxOpenConns(1) + connection, err := db.Conn(ctx) + if err != nil { + return out, err + } + defer connection.Close() + for _, statement := range append([]string{"use `" + database + "`"}, cfg.Session...) { + if _, err = connection.ExecContext(ctx, statement); err != nil { + return out, err + } + } + info, err := os.Lstat(cfg.Scratch) + if err != nil { + return out, err + } + if !info.IsDir() || info.Mode().Perm() != 0700 { + return out, errors.New("scratch root is not private") + } + root, err := os.OpenRoot(cfg.Scratch) + if err != nil { + return out, err + } + defer root.Close() + name := fmt.Sprintf("run-%08d-%s.ndjson", spec.Sequence, spec.Route) + file, err := root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return out, err + } + defer func() { + _ = file.Close() + if err != nil { + _ = root.Remove(name) + } + }() + statement := string(sqlBytes) + if spec.Route == "embedded-mo" { + statement = "/*+ SIDECAR GPU */ " + statement + } + queryCtx, cancelQuery := context.WithCancel(ctx) + defer cancelQuery() + started := time.Now() + rows, err := connection.QueryContext(queryCtx, statement) + if err != nil { + return out, err + } + out.Result, out.FirstRow, err = drainResult(rows, file, spec.SpoolBudget, started, cancelQuery) + if err != nil { + return out, err + } + if err = file.Close(); err != nil { + return out, err + } + out.WallSeconds = time.Since(started).Seconds() + if err = connection.QueryRowContext(ctx, "select last_query_id()").Scan(&out.QueryID); err != nil { + return out, err + } + out.SchemaVersion, out.Sequence, out.Route = 2, spec.Sequence, spec.Route + out.Result.Spool = name + return out, nil +} + +func main() { + if len(os.Args) == 4 && os.Args[1] == "load" { + cfg, err := readConfiguration(os.Getenv("MO_SIRIUS_CAMPAIGN_CONFIG")) + if err != nil { + _, _ = os.Stderr.WriteString("dataset configuration rejected\n") + os.Exit(1) + } + ctx, cancel := context.WithTimeoutCause(context.Background(), 6*time.Hour, errors.New("dataset provisioning deadline")) + defer cancel() + result, err := loadDataset(ctx, cfg, os.Args[2], os.Args[3]) + if err != nil { + _, _ = os.Stderr.WriteString("dataset provisioning failed\n") + os.Exit(1) + } + if err = json.NewEncoder(os.Stdout).Encode(result); err != nil { + os.Exit(1) + } + return + } + var spec runSpec + input := os.Getenv("MO_SIRIUS_CAMPAIGN_RUN") + cfg, err := readConfiguration(os.Getenv("MO_SIRIUS_CAMPAIGN_CONFIG")) + if err == nil && len(input) <= 16<<10 { + err = json.Unmarshal([]byte(input), &spec) + } else if err == nil { + err = errors.New("run specification too large") + } + if err != nil { + _, _ = os.Stderr.WriteString("campaign configuration rejected\n") + os.Exit(1) + } + parent, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeoutCause(parent, time.Duration(cfg.TimeoutSeconds)*time.Second, errors.New("campaign query deadline")) + defer cancel() + out, err := run(ctx, cfg, spec) + if err != nil { + // Neither DSNs, SQL, paths nor driver-controlled diagnostics cross stdout. + _, _ = os.Stderr.WriteString("campaign query failed\n") + os.Exit(1) + } + if err = json.NewEncoder(os.Stdout).Encode(out); err != nil { + os.Exit(1) + } +} diff --git a/cmd/mo-sirius-campaign/main_test.go b/cmd/mo-sirius-campaign/main_test.go new file mode 100644 index 0000000000000..27dc3251b5713 --- /dev/null +++ b/cmd/mo-sirius-campaign/main_test.go @@ -0,0 +1,60 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "testing" + +func TestExactPublicCodec(t *testing.T) { + for _, test := range []struct { + input string + precision, scale int64 + want string + }{ + {"-0.00", 3, 2, "0"}, + {"1.2", 4, 2, "120"}, + {"-340282366920938463463374607431768211457.12345678", 65, 8, "-34028236692093846346337460743176821145712345678"}, + } { + got, err := exactDecimal(test.input, test.precision, test.scale) + if err != nil || got != test.want { + t.Fatalf("exact decimal: got %s, %v", got, err) + } + } + for _, input := range []string{"1e2", "NaN", "1.234", "1234", ".1", "--1"} { + if _, err := exactDecimal(input, 3, 2); err == nil { + t.Fatalf("accepted invalid decimal %s", input) + } + } + for _, test := range []struct{ typ, value, want string }{ + {"BIGINT", "-9223372036854775808", "int"}, + {"UNSIGNED BIGINT", "18446744073709551615", "uint"}, + {"DATE", "0000-00-00", "date"}, + } { + got, err := encodeCell(publicColumn{SQLType: test.typ}, []byte(test.value)) + if err != nil || got.Type != test.want || got.Value != test.value { + t.Fatalf("public scalar: %+v %v", got, err) + } + } + if _, err := encodeCell(publicColumn{SQLType: "UNSIGNED BIGINT"}, []byte("-1")); err == nil { + t.Fatal("accepted negative unsigned integer") + } + got, err := encodeCell(publicColumn{SQLType: "VARCHAR"}, nil) + if err != nil || !got.Null { + t.Fatal("lost NULL") + } + got, err = encodeCell(publicColumn{SQLType: "VARCHAR"}, []byte{}) + if err != nil || got.Null || got.Value != "" { + t.Fatal("empty string became NULL") + } +} diff --git a/cmd/mo-sirius-campaign/run_test.go b/cmd/mo-sirius-campaign/run_test.go new file mode 100644 index 0000000000000..3b8f80aea24fb --- /dev/null +++ b/cmd/mo-sirius-campaign/run_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" +) + +func publicRunFixture(t *testing.T) (configuration, runSpec, string) { + t.Helper() + root := t.TempDir() + scratch := filepath.Join(root, "scratch") + require.NoError(t, os.Mkdir(scratch, 0700)) + statement := "select amount from t" + require.NoError(t, os.WriteFile(filepath.Join(root, "q1.sql"), []byte(statement), 0600)) + digest := sha256.Sum256([]byte(statement)) + return configuration{DSN: "runner:private@tcp(127.0.0.1:3306)/", QueryDirectory: root, Scratch: scratch, + Databases: map[string]string{"1": "tpch"}, Session: []string{"set time_zone='+00:00'"}, TimeoutSeconds: 1}, + runSpec{Sequence: 1, Route: "mo-native", Query: 1, QuerySHA256: hex.EncodeToString(digest[:]), ScaleFactor: 1, SpoolBudget: 1 << 20}, statement +} + +func TestPublicRunOwnsOneConnectionAndPublishesOnlyCompleteSpools(t *testing.T) { + failed := errors.New("owned SQL failure") + for _, scenario := range []string{"native", "embedded", "query-error", "late-error", "quota", "identity-error"} { + t.Run(scenario, func(t *testing.T) { + cfg, spec, statement := publicRunFixture(t) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + mock.ExpectExec("use `tpch`").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(cfg.Session[0]).WillReturnResult(sqlmock.NewResult(0, 0)) + if scenario == "embedded" { + spec.Route, statement = "embedded-mo", "/*+ SIDECAR GPU */ "+statement + } + query := mock.ExpectQuery(statement) + if scenario == "query-error" { + query.WillReturnError(failed) + } else { + data := sqlmock.NewRowsWithColumnDefinition(sqlmock.NewColumn("amount").OfType("DECIMAL", "").WithPrecisionAndScale(6, 2)).AddRow("1.20") + if scenario == "late-error" { + data.AddRow("2.30").RowError(1, failed) + } + query.WillReturnRows(data).RowsWillBeClosed() + if scenario == "quota" { + spec.SpoolBudget = 1 + } + if scenario != "late-error" && scenario != "quota" { + identity := mock.ExpectQuery("select last_query_id()") + if scenario == "identity-error" { + identity.WillReturnError(failed) + } else { + identity.WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(strings.Repeat("a", 32))) + } + } + } + mock.ExpectClose() + out, err := runWithOpener(t.Context(), cfg, spec, func(driver, dsn string) (*sql.DB, error) { + require.Equal(t, "mysql", driver) + require.Equal(t, cfg.DSN, dsn) + return db, nil + }) + entries, readErr := os.ReadDir(cfg.Scratch) + require.NoError(t, readErr) + if scenario == "native" || scenario == "embedded" { + require.NoError(t, err) + require.Len(t, entries, 1) + require.Equal(t, 2, out.SchemaVersion) + require.Equal(t, spec.Route, out.Route) + require.Equal(t, uint64(1), out.Result.Rows) + require.Equal(t, strings.Repeat("a", 32), out.QueryID) + require.NotNil(t, out.FirstRow) + path := filepath.Join(cfg.Scratch, out.Result.Spool) + data, readErr := os.ReadFile(path) + require.NoError(t, readErr) + require.Contains(t, string(data), `"value":"120"`) + digest := sha256.Sum256(data) + require.Equal(t, hex.EncodeToString(digest[:]), out.Result.SHA256) + info, statErr := os.Stat(path) + require.NoError(t, statErr) + require.Equal(t, os.FileMode(0600), info.Mode().Perm()) + } else { + require.Error(t, err) + require.Empty(t, entries, "failed queries cannot retain a partial spool") + require.Zero(t, out.SchemaVersion) + if scenario != "quota" { + require.ErrorIs(t, err, failed) + } + } + require.Equal(t, 1, db.Stats().MaxOpenConnections) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestPublicRunRejectsImmutableInputsBeforeOpeningSQL(t *testing.T) { + for _, scenario := range []string{"sequence", "query", "route", "budget", "missing-query", "digest", "database", "dsn", "driver-options"} { + t.Run(scenario, func(t *testing.T) { + cfg, spec, _ := publicRunFixture(t) + switch scenario { + case "sequence": + spec.Sequence = 0 + case "query": + spec.Query = 23 + case "route": + spec.Route = "unknown" + case "budget": + spec.SpoolBudget = (4 << 30) + 1 + case "missing-query": + require.NoError(t, os.Remove(filepath.Join(cfg.QueryDirectory, "q1.sql"))) + case "digest": + spec.QuerySHA256 = strings.Repeat("0", 64) + case "database": + cfg.Databases["1"] = "unsafe`database" + case "dsn": + cfg.DSN = "not-a-dsn" + case "driver-options": + cfg.DSN += "?multiStatements=true" + } + _, err := runWithOpener(t.Context(), cfg, spec, func(string, string) (*sql.DB, error) { + t.Fatal("invalid input reached database acquisition") + return nil, nil + }) + require.Error(t, err) + }) + } +} + +func TestPrivateConfigurationRequiresBoundedRegularPrivateInput(t *testing.T) { + cfg, _, _ := publicRunFixture(t) + path := filepath.Join(t.TempDir(), "runner.json") + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0600)) + actual, err := readConfiguration(path) + require.NoError(t, err) + require.Equal(t, cfg, actual) + for _, scenario := range []string{"public", "oversize", "malformed", "incomplete", "symlink", "missing"} { + t.Run(scenario, func(t *testing.T) { + name := filepath.Join(t.TempDir(), "input") + require.NoError(t, os.WriteFile(name, data, 0600)) + switch scenario { + case "public": + require.NoError(t, os.Chmod(name, 0644)) + case "oversize": + require.NoError(t, os.Truncate(name, maxRowBytes+1)) + case "malformed": + require.NoError(t, os.WriteFile(name, []byte("{"), 0600)) + case "incomplete": + require.NoError(t, os.WriteFile(name, []byte("{}"), 0600)) + case "symlink": + require.NoError(t, os.Remove(name)) + require.NoError(t, os.Symlink(path, name)) + case "missing": + require.NoError(t, os.Remove(name)) + } + _, err := readConfiguration(name) + require.Error(t, err) + }) + } +} + +func TestPublicRunAcquisitionFailurePreservesOwner(t *testing.T) { + cfg, spec, _ := publicRunFixture(t) + failed := errors.New("open owner") + _, err := runWithOpener(context.Background(), cfg, spec, func(string, string) (*sql.DB, error) { return nil, failed }) + require.ErrorIs(t, err, failed) +} diff --git a/docs/design/sirius-public-campaign.md b/docs/design/sirius-public-campaign.md new file mode 100644 index 0000000000000..2dcd3b283f60e --- /dev/null +++ b/docs/design/sirius-public-campaign.md @@ -0,0 +1,369 @@ +# Public Sirius acceptance campaign + +Design version: 3. Owners: MatrixOne public SQL, compile/CN evidence and +`optools` campaign tooling. Tracks MO #28968 and parent #28966. + +The user approved this design before implementation on 2026-10-09 and selected +one complete D PR, combining runner, lifecycle tests and full campaign evidence. +The approved implementation sequence remains #29690. C #29757 merged as af5a75c5f9. D starts from authoritative main containing +that merge. Sirius #30 merged as 955b6881 on 2026-10-11; D now pins that merged +native source for the guarded release SDK. The user approved this +implementation revision on 2026-10-11 after selecting one complete D PR and +separate allocation profiling without a native observability prerequisite. E/F and Docker changes are excluded. + +## Contract and choice + +For every admitted campaign cell, the immutable dataset, canonical SQL, public +result/error metadata and exact values must agree between native MO and embedded +MO-reader execution. An embedded result additionally requires matching query +identity, real completed GPU work, exact capability/profile, no fallback and +healthy terminal cleanup. The negation is any descriptor/value/NULL/error drift, +misattributed telemetry, incomplete schedule, missing required evidence or +unbounded retained state. No benchmark SQL rewrite, float coercion, unchecked +narrowing or skipped required query can satisfy the contract. + +Version 1 of `optools/sirius_parity.py` requires five routes including embedded +TAE, materializes rows in runner JSON and retains complete results. The selected +version 2 requires native MO and embedded MO only, uses bounded transient spools, +and retains safe summaries. Leave version-1 historical records/tests readable; +an explicitly versioned v2 input selects the new contract. Extending the legacy +TAE route would violate the approved MO-reader scope. Keeping rows in memory +would violate result-size bounds. Splitting runner and acceptance PRs was offered +and rejected by the user. D stays draft while implementation or required local +evidence is incomplete; final acceptance also requires CI and reviewer approval. + +## Routes, data and schedule + +Required routes are `mo-native` (ordinary public SQL) and `embedded-mo` (the same +SQL with the existing explicit GPU hint). Optional Flight profiles declare their +equivalent supported query coverage at preflight. Unsupported/unavailable +comparisons are N/A. A later execution failure cannot become an unsupported +query, and partial coverage is never reported as a full-suite total. Version 2 +has no embedded-TAE route or gate. Legacy v1 artifacts retain their old meaning. + +Freeze a manifest per SF1/SF10 dataset containing generator/source-file hashes, +DDL/table schema, row counts and a dataset digest. Use decimal TPC-H quantities +and the existing canonical Q1-Q22 SQL; do not replace these with the planner's +legacy integer-quantity DDL. Record SQL hashes, exact MO/Sirius/SDK revisions, +compiler/Pixi/CUDA/driver versions, hardware, resource limits and session settings. +Sidecar provenance is required only for declared Flight profiles. Dataset writes +are prohibited during the campaign; ordinary MO snapshot/GC ownership remains. +Flight-TAE comparisons, when supplied, retain their separate launcher-verified +TN no-GC contract and are explicitly labeled historical-reader comparisons. + +For each scale, run Q1-Q22 with streams 1 and 4 as correctness controls, then +streams 2 with one excluded warm-up and five measured suites, followed by ten +separate Q9 repetitions. Rotate route order deterministically; never overlap GPU +benchmarks. The required two-route schedule has 744 executions across both scales. +Keep stream controls outside performance samples and distinguish cold/warm runs. + +## Executor, interfaces and ownership + +A Go runner uses the existing MySQL driver and consumes one immutable RunSpec +through the existing `MO_SIRIUS_CAMPAIGN_RUN` interface. It produces one bounded +JSON descriptor, not a JSON array of rows. Configuration/secrets are supplied by +private configuration files, never echoed into diagnostics or shell arguments. +The controller owns an isolated service workspace, PID identities and shutdown. +A separate campaign worker owns runner descendants through the existing exclusive +Linux subreaper cleanup. Long-lived services are siblings of that worker, so a +failed runner cannot reap the controller's services. Keep services alive through +warm-up/measured suites and restart embedded CN only for stream-profile changes. + +Public rows and ColumnTypes provide the independent native-MO oracle. Obtain +physical OID/precision/scale/nullability through the native frontend's existing +bound-result-column owner, with bounded default-off query-local schema evidence. +Match it to embedded verified output descriptors; do not infer physical decimal +width from precision. Evidence contains account/query identity, descriptors and +schema digest, with no raw headings or values. This adds no result protocol field, +SQL endpoint, persisted format, global result registry or new numeric ABI layout. + +The v2 result descriptor carries complete schema, row count, spool-relative name, +byte count and digest. The worker accepts only its own regular files beneath the +private scratch root and rejects traversal, symlinks, duplicate ownership, +truncation, digest mismatch and trailing data. One owner releases each spool after +its oracle pair validates, and on every exception/cancel/timeout path. Partial +phase workers cannot publish a complete acceptance result; final schedule +validation owns the full-campaign verdict. + +Encode decimal coefficients/scale exactly without floating conversion, preserve +integer signedness and NULLs, and validate empty results with their schema. Use +at most 32 MiB comparator memory and 4 GiB total scratch, with directory mode +0700/files 0600. Quota exhaustion is a failure. Compare streaming ordered rows; +ties may be compared as multisets within their SQL ordering peer group, without +adding SQL tiebreakers. Different LIMIT-boundary membership fails exact parity. +Unordered results use bounded external comparison and retain duplicate counts. +Floating tolerance is disabled by default and applies only to explicitly +approved approximate columns. Validation never relies solely on digests. + +Wall time begins immediately before query dispatch and ends after full result +draining/streaming serialization. First-row time is observed on the first actual +row; empty results record absence. Setup, telemetry collection, sorting and +comparison are excluded. Record backend timing and PID-scoped CPU measurements +separately. Fetch actual query identity and drain late SQL errors before accepting +success; producer cleanup must not replace the numeric error owner. + +## Resources, failure and artifacts + +Keep 64 MiB input/result windows, one active native query and sixteen waiters. +Existing leases, task resources, result callbacks and joined close paths own all +transfers. Do not substitute charged-byte counters for measured memory peaks. +Collect existing allocator/pool observations and PID-scoped host/CPU/GPU sampling, +recording source, units, interval and query/process/lifetime scope. Missing required +evidence blocks acceptance; GPU/pinned fields are N/A only for native MO. Compare +query-owned counters and warmed allocated-pool/process baselines after cleanup. + +Stop on the first malformed output, missing/misattributed event, public mismatch, +unexpected cancellation or unhealthy cleanup. Preserve current fail-fast behavior, +empty-output-directory preflight and fixed sanitized `failure.json`. Retain only +`campaign.json`, sanitized `runs.jsonl`, schema/count/digest summaries, timing +tables, lifecycle/resource evidence, baseline results and provenance. Remove raw +row spools. Failure cleanup closes SQL handles, stops the owned runner process +group, joins descendants and closes only controller-owned services; unknown +quiescence is fatal and cannot be labeled healthy reuse. + +## Validation and readiness + +Extend existing tests for typed/schema drift, 0/1/2-row results, duplicates, +spool boundaries/corruption, quota exhaustion, wrong identity, duplicate/missing +events, late errors, timeouts, private-output ownership and partial schedules. +Reuse existing public/native/bridge fixtures before adding expensive setup. +Map each behavior to the cheapest independent oracle and keep benchmark cost out +of ordinary unit-test assertions. + +Lifecycle evidence includes paused consumption, blocked input/result credit, +cancel before admission and during work, writer failure, queue saturation, +operation-owned errors, ordinary healthy reuse and fatal quarantine. Use observable +barriers and existing fault seams, with zero retained query credit after close. +Reuse semantically valid native proofs and produce current-consumer evidence for +any changed boundary. Run affected owning suites and applicable race/static gates. + +Publish complete Q1-Q22 medians/sums, separately labeled median suite times, +Q9 results and raw repetitions. Enforce the existing 10% numeric regression +policy with fresh matched baselines: Native A for historical scalar regressions +(especially checked-add64), Native B for aggregates, identical shared case IDs, +operands, toolchain/hardware and timing boundaries. Missing required baseline +evidence means incomplete acceptance. Preserve the Flight+MO ratio limit 1.0 +where equivalent coverage exists. No performance waiver is implied. + +Reconcile warmed process/pool and sanitizer baselines, including the existing +41,386,248-byte cuDF pinned-pool report and generic CUB diagnostic. Unresolved +value, lifecycle/resource, sanitizer or performance gates keep D draft. Default +cutover and Flight retirement remain E/F under a separate recovery-readiness +design; a nil/unready lease manager is never proof of no old consumers. + +Design review: PASS for the approved contract and ownership model. Remaining +execution gates are the merged native prerequisite and terminal release acceptance +evidence. C is merged; immutable SF1/SF10 inputs and matched numeric baselines +are now provisioned and verified. Implementation +deviations require this document and affected decisions to be reviewed again. + +## Revision 3 implementation decisions + +The 744 timing executions remain unprofiled. A separate resource schedule runs +132 native/embedded pairs (Q1-Q22, SF1/SF10, streams 1/2/4), plus explicitly +excluded warmups. Nsight Systems captures CUDA allocation/free records from CN +startup; SQLite memKind classifies pinned/device allocations. Existing tagged +pool observations and PID/Go allocator sampling remain separate sources. +Profiling never supplies benchmark timings and adds no numeric ABI or native API. + +The session-only mo_sirius_campaign_evidence boolean defaults off. It emits +bounded executed-generation OID/precision/scale/nullability and the shared +Sirius schema digest, without names or values. The public runner retrieves +last_query_id() on the same physical connection after full drain and matches +exactly one event by account, query UUID and service generation. + +The Go public runner consumes MO_SIRIUS_CAMPAIGN_RUN and private configuration, +streams typed NDJSON rows to 0600 relative spools beneath a 0700 scratch root, +and returns only bounded metadata/count/bytes/digest. Rows are bounded to 1 MiB; +comparison uses 32 MiB working memory and 4 GiB total comparison scratch. +V1 dispatch, routes, artifacts and tests retain their meaning. + +The controller owns persistent separate LOG/TN/CN roles. The worker and services +are siblings. Both the worker and controller enter exclusive subreaping; the +outer controller joins descendants orphaned by a failed worker. CN restarts occur +only at stream-profile changes, retaining committed immutable datasets. +Defaults: 6 GiB GPU, 16 GiB native host, existing 64 MiB windows, one active plus +sixteen waiting queries, one-hour query and three-hour profiling deadlines. + +Merge readiness requires all exact/public/cleanup/resource/sanitizer/performance gates, +matched Native A/B/current baselines and required CI/reviewer approval. The +known cuDF pinned-pool and CUB reports require matched controls and steady +allocation evidence. Complete implementation and local evidence permit marking +D ready for review. Required CI and reviewer approval gate final acceptance and +merge. E/F and Docker work remain excluded. + +## Implementation and execution + +Version 2 requires Python 3.11 or newer and the public Go consumer at +`cmd/mo-sirius-campaign`. `optools/sirius_campaign.py serve` creates separate +LOG/TN/CN roles in a private workspace for loading. Preserve that workspace's +configuration and durable data across subsequent runs. Provision each scale +with `optools/sirius_dataset.py` and the runner's `load` command. The load report +checks each source CSV and compares the loaded table's typed row multiset with +the generator; a matching row count alone is insufficient. + +Use `sirius_campaign.py freeze` with the source root, packaged service, runner, +dataset directory and both load reports to capture actual source/binary/SDK, +hardware and toolchain identities. Credentials remain in private `runner.json`. +`run` consumes that frozen provenance and an empty output directory; add +`--resource` for the separate allocation campaign. Run serially, without +concurrent builds, GPU tests or dataset writes. The controller owns service +shutdown and deletes invocation-owned row spools and raw trace/log workspaces. +Its retained artifacts include safe run records, CSV repetitions, Markdown +timing tables and resource summaries. + +For diagnosis after an interrupted resource run, `--resource --resource-phases +5 6` runs only SF10 streams 4 and 2, with their original schedule identities. +Phases 1..6 are SF1 streams 1/4/2 followed by SF10 streams 1/4/2. This mode +publishes `resource-diagnostic.json` with `partial: true`, `complete: false` and +`diagnostic-runs.jsonl`, after joined cleanup. It never writes a complete campaign +summary or timing verdict, even when all six phases are selected. Preserve each +capture's actual provenance; diagnostic or mixed-source captures cannot replace +the uniform complete release campaign. + +Each resource configuration runs an excluded complete warmup suite and a final +Q1 cleanup probe. Existing native tagged pool snapshots remain distinct from +Nsight physical allocation/free events and PID sampling. Native pool peak tags +are lifetime high-water marks; they are never presented as query-only peaks. +Nsight exports normalize timestamps to the Unix epoch before matching bounded +runner intervals. Allocation tracing must start with CN and report complete +CUDA collection. Native MO GPU/pinned query measurements remain N/A. + +The owned CN admits one GPU query at a time. MO's public UUID and the native +engine's independently minted UUID are distinct; complete, consecutive native +window accounting attributes existing pool tags by admission order. QueryEnd +is logged before the embedded engine/plan destruction. Preserve that snapshot; +when it retains GPU bytes, require the actual successor window's idle begin to +show zero allocated/reserved bytes after public drain/close. Never relabel the +earlier snapshot as zero or accept an unidentified successor. + +Nsight launches through a private PID/start-generation record written before +exec. The controller also owns an outer subreaper, so it joins escaped runner +descendants if the inner worker dies. It publishes full campaign artifacts only +after all owned roles and descendants quiesce. Exception diagnostics include +only a fixed stage, sequence and repository source line, with no error body. +Allocation capture disables optional all-API and device-event tracing. Private +profile workspaces have a separate 48 GiB cap, including profiler intermediates; +each exported SQLite trace is bounded to 16 GiB and parsed with an 8 MiB cache. +The live allocation map holds at most 65,536 generations in memory, then spills +exact acquisition/free matching to a private SQLite ledger with an 8 MiB cache +and a strict 4 GiB page quota. Duplicate generations and unmatched frees remain errors. +SQLite's event ordering also uses temporary sorting files; see its +[sorting implementation](https://github.com/sqlite/sqlite/blob/master/src/vdbesort.c). +The exclusive parser disables memory mapping and worker threads, uses private +temporary storage, limits address space to 1 GiB and each sorting file to 8 GiB, +and restores the environment, limits and signal handler after success or failure. +Reserve the two sorting files and the ledger against the invocation's 48 GiB +quota. Retain safe per-phase query envelopes before deleting each joined raw +phase workspace so completed traces cannot accumulate across configurations. +Report generation and export have the bounded three-hour profiling deadline; +SF10 intermediates and conversion take longer than the small SF1 preflight. +The first SF1 capture contained 2.55 million memory events and a 717 MiB SQLite +export; the separate trace bounds allow the required tenfold scale. Go's direct +`exit_group` skips native atexit finalizers and leaves Nsight's CUDA trace +incomplete. Allocation profiling runs the unchanged packaged service under the +bounded GDB exit wrapper in `optools/sirius_profile_exit.gdb`. It validates the +linux/amd64 runtime exit instruction sequence, preserves nonzero exits, and +invokes libc exit after successful Go shutdown so native finalizers flush CUDA. +This is profiling-only; the 744 timing executions remain uninstrumented. The +wrapper participates in the frozen collector hash. Nsight discards environment +snapshots. The physical scope covers CN startup through process destruction. +Reject CUDA/CUPTI completeness warnings for that PID or global scope; +unrelated traced child processes cannot invalidate PID-scoped measurements. +The result/comparison scratch quota remains 4 GiB. Unprovisioned Flight profiles +are declared N/A before execution. + +The 64 MiB native input windows apply to each binding; aggregate input credit +across several bindings can exceed 64 MiB. Bindings are limited to sixteen. +Shared CTE execution revealed that the native importer inlines reference +relations while MO input queues are destructive. The compiler now gives each +inlined occurrence an independent reader on the same statement snapshot, +without changing canonical SQL or Flight's shared-step behavior. Expansion has +node, depth and reader bounds and preserves the caller's plan. Sirius #30 also +preserves retained VARCHAR identity casts and rejects reused/unreachable MO +inputs before native work starts. The release pin remains merged code; unmerged +development validation is explicitly labeled and cannot satisfy final acceptance. + +`optools/sirius_acceptance.py` combines complete timing/resource records with +actual lifecycle, sanitizer and matched numeric baseline evidence. Missing, +partial, changed or misattributed evidence fails closed. Passing local gates +sets `local_acceptance_complete`; required CI and reviewer approval still gate +final acceptance. The current implementation does not infer those decisions +from a local test or an incomplete run. + +Early consumer failures cancel the SQL query before closing unread rows, so the +MySQL driver's close cannot keep draining a blocked producer until the query +deadline. Cancellation affects only that query's child context; the retained +failure still includes its original value/writer/quota and observed close errors. +Acceptance crosslinks allocation baselines to the actual measured/probe snapshots +and checks unique, ordered CUDA spans. Matched sanitizer classifications retain +raw exit status 99 and the exact candidate/control error and leak counts; extra +findings or duplicate controls fail acceptance. + +Retained summaries reconnect each mandatory and declared optional route with its +native-MO oracle cell. Cardinality, physical descriptors and schema identity must +agree after spools retire. Raw spool hashes need not agree for unordered rows or +ordered peers; the live full-row comparator remains the value oracle. The final +artifact command checks the archive's recorded `checksums.json`, including every +consumed input, and parses the same bytes it hashed. A missing, changed or +redirected artifact fails before acceptance. `--checksums` can select the manifest +explicitly; its default is the timing directory's parent. + +The controller also checks the whole workspace during target/readiness waits, +profiler finalization, export and shutdown. The exporter uses the existing owned +Service process abstraction, with the same three-hour finalization/export bound +and regular quota checks. A quota or deadline failure kills and joins that owner; +adopted descendants still join through the outer controller. Failed cleanup does +not restore admission or publish complete evidence. Small live-writer tests +exercise quota growth during finalization/export and preserve the original +reduced-quota counterexamples. + +## Release validation record + +The generated development and release captures are preserved in contributor-fork +tag `evidence/sirius-d-4230ce3d14`, at commit +`4230ce3d143e77a8b4be83feb47bd26858a8d459`. The +[immutable release evidence tree](https://github.com/aunjgr/matrixone/tree/4230ce3d143e77a8b4be83feb47bd26858a8d459/docs/design/evidence/sirius-campaign-release) +contains the original observations, provenance, numeric samples, lifecycle and +sanitizer proofs, source qualifications and review-fix evidence. These generated +records are outside the current source tree; application code and regression +tests use no checked-in campaign captures. Earlier history remains intact. + +The measured source is MO `9814347c1ce38ec12f51412ef3176ede1501574d`, merged Sirius +`955b6881afa6e92d0995f43e3fe9c383d86981c0`, and guarded release SDK +`29bca11a537abf00d481581a423023591dbabda9b0fdd73d06c4a697e8ce01ce`. +All 744 unprofiled timing and 264 resource executions passed; six allocation +profiles had zero unexplained warmed growth and all ten numeric ratios passed +1.1 (maximum 1.026527). Raw sanitizer status 99 is preserved with matched Native A +CUB and no-GPU-work 41,386,248-byte cuDF pinned-resource controls. Median native/GPU +suite times were 1.804130/4.789524 seconds at SF1 and 27.947777/52.826967 at SF10; +the GPU route was slower overall on the recorded RTX 3070. Original contexts, +binary hashes, PIDs and measurements are preserved. Earlier captures do not +retrospectively certify the later continuous disk-quota monitor. + +To verify the original archive with the current validator, fetch the preservation +tag and export into a temporary workspace; the source checkout need not change: + +```sh +git fetch https://github.com/aunjgr/matrixone.git refs/tags/evidence/sirius-d-4230ce3d14 +task_evidence_dir=$(mktemp -d) +git archive --format=tar 4230ce3d143e77a8b4be83feb47bd26858a8d459 \ + docs/design/evidence/sirius-campaign-release > "$task_evidence_dir/release-evidence.tar" +printf '%s %s\n' \ + 28cfb19a161bc89aa917bf9add42e496b425a3b71f63399670d6daa43cfb269e \ + "$task_evidence_dir/release-evidence.tar" | sha256sum -c - +tar -xf "$task_evidence_dir/release-evidence.tar" -C "$task_evidence_dir" +task_evidence_root="$task_evidence_dir/docs/design/evidence/sirius-campaign-release" +python3 optools/sirius_acceptance.py \ + --timing "$task_evidence_root/timing" --resource "$task_evidence_root/resource" \ + --allocations "$task_evidence_root/allocations.json" \ + --lifecycle "$task_evidence_root/lifecycle.json" \ + --sanitizer "$task_evidence_root/sanitizer.json" \ + --baselines "$task_evidence_root/numeric-baselines.json" +``` + +The archive SHA-256 is fixed above; its internal `checksums.json` has SHA-256 +`98cc3821567fd995c8f129a2370e85308cb179940efc8fb49df86d7f11dfa7bb`. +The unchanged CLI verifies every listed artifact and returns +`local_acceptance_complete: true`. CI and reviewer approval remain separate final +acceptance and merge gates; an archived local result does not infer them. diff --git a/optools/sirius_acceptance.py b/optools/sirius_acceptance.py new file mode 100644 index 0000000000000..7e78719ee5787 --- /dev/null +++ b/optools/sirius_acceptance.py @@ -0,0 +1,642 @@ +#!/usr/bin/env python3 +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Fail-closed final acceptance of complete, source-matched v2 evidence.""" +import argparse +import hashlib +import json +import math +from pathlib import Path +import statistics + +import sirius_campaign_v2 as campaign + +LIFECYCLE_CASES = { + "paused-consumption", + "blocked-input-credit", + "blocked-result-credit", + "cancel-before-admission", + "cancel-during-work", + "writer-failure", + "queue-saturation", + "numeric-error-owner", + "healthy-reuse", + "fatal-quarantine", +} + +# Shared IDs from the actual A/B/current harnesses. Scalar controls also keep +# their matched timing boundaries; aggregates first exist in Native B. +BASELINE_CASES = { + "cudf_add64": "scalar", + "cudf_widen_multiply64_to128": "scalar", + "checked_add64": "scalar", + "checked_multiply64_to128": "scalar", + "checked_add256": "scalar", + "checked_divide256": "scalar", + "ordinary_expression_add64": "scalar", + "exact_expression_add64": "scalar", + "cudf_sum64_to128": "aggregate", + "exact_sum64_to128": "aggregate", +} + + +def _record_identity(reference, actual): + for key in ( + "matrixone_revision", + "sirius_revision", + "sdk_sha256", + "query_sha256", + "datasets", + "hardware", + "config", + "toolchain", + "service_binary_sha256", + "runner_binary_sha256", + "sdk_build_mode", + "controller_sha256", + ): + if actual.get(key) != reference.get(key): + raise campaign.CampaignError("acceptance evidence semantic inputs differ") + + +def validate( + timing_campaign, + timing_records, + resource_campaign, + resource_records, + allocations, + lifecycle, + sanitizer, + baselines, +): + timing_summary = campaign.summarize(timing_campaign, timing_records) + campaign.summarize(resource_campaign, resource_records) + if ( + timing_campaign["resource_campaign"] + or not resource_campaign["resource_campaign"] + ): + raise campaign.CampaignError("timing/resource evidence misclassified") + identity = timing_campaign["provenance"] + if identity["sdk_build_mode"] != "release": + raise campaign.CampaignError("final acceptance requires a merged release SDK") + _record_identity(identity, resource_campaign["provenance"]) + if len({row["query_id"] for row in timing_records + resource_records}) != len( + timing_records + ) + len(resource_records): + raise campaign.CampaignError( + "timing/resource campaigns reused a query identity" + ) + by_query = {row["query_id"]: row for row in timing_records + resource_records} + for spec, record in zip( + timing_campaign["schedule"] + resource_campaign["schedule"], + timing_records + resource_records, + ): + required = {"host_process", "go_heap", "cpu", "gpu_residency"} + if not required <= set(record.get("observations", {})): + raise campaign.CampaignError("timing measurement source missing") + stats = record.get("execution_stats") + if spec["backend"] == "embedded": + if ( + not isinstance(stats, dict) + or stats.get("terminal") is not True + or stats.get("fatal") is not False + or stats.get("gpu_tasks_completed", 0) <= 0 + or stats.get("mo_input_retained_charged_bytes") != 0 + or stats.get("result_retained_charged_bytes") != 0 + ): + raise campaign.CampaignError("timing terminal ownership proof missing") + if record["observations"]["gpu_residency"].get("samples", 0) <= 0: + raise campaign.CampaignError("actual PID GPU measurement missing") + pools = record.get("native_pools", {}) + if ( + pools.get("source") != "sirius-tagged-pools" + or pools.get("scope") != "query-window" + or {pool.get("tier") for pool in pools.get("pools", [])} + != {"host", "gpu"} + ): + raise campaign.CampaignError("logical native pool measurement missing") + for pool in pools["pools"]: + if pool["tier"] != "gpu": + continue + snapshot = pool.get("end", {}) + if ( + snapshot.get("allocated_bytes") + == snapshot.get("reserved_bytes") + == 0 + ): + continue + observed = pool.get("after_close", {}) + successor = by_query.get(observed.get("query_id"), {}) + next_pools = successor.get("native_pools", {}) + matching = [ + item + for item in next_pools.get("pools", []) + if (item.get("tier"), item.get("space")) + == (pool["tier"], pool.get("space")) + ] + if ( + observed.get("source") != "next-query-window-begin" + or type(pools.get("window")) is not int + or observed.get("window") != pools["window"] + 1 + or next_pools.get("window") != observed["window"] + or successor.get("scale_factor") != record["scale_factor"] + or successor.get("streams") != record["streams"] + or len(matching) != 1 + or any( + observed.get(key) != matching[0].get("begin", {}).get(key) + for key in ( + "allocated_bytes", + "reserved_bytes", + "lifetime_peak_bytes", + ) + ) + or observed.get("allocated_bytes") != 0 + or observed.get("reserved_bytes") != 0 + ): + raise campaign.CampaignError( + "native logical GPU pool retains query state" + ) + elif record["observations"]["gpu_residency"] != {"status": "N/A"}: + raise campaign.CampaignError("native oracle GPU scope misrepresented") + expected_profiles = {(scale, stream) for scale in (1, 10) for stream in (1, 2, 4)} + if ( + set((entry.get("scale_factor"), entry.get("streams")) for entry in allocations) + != expected_profiles + or len(allocations) != 6 + ): + raise campaign.CampaignError("allocation profile coverage incomplete") + physical_query_ids = set() + for allocation in allocations: + _record_identity(identity, allocation.get("provenance", {})) + if ( + allocation.get("source") != "nsight-cupti-memory-events" + or allocation.get("scope") != "cn-process-lifetime" + or any( + allocation.get("allocation_counts", {}).get(kind, 0) <= 0 + for kind in ("pinned", "device") + ) + ): + raise campaign.CampaignError("required physical allocation source missing") + if ( + allocation.get("unexplained_growth_bytes") != 0 + or allocation.get("pool_baseline_reconciled") is not True + ): + raise campaign.CampaignError("allocator/pool lifetime baseline unresolved") + spans = allocation.get("runs", []) + if ( + sum(span.get("phase") == "resource-validation" for span in spans) != 22 + or sum(span.get("phase") == "resource-warmup" for span in spans) != 22 + or sum(span.get("phase") == "cleanup-probe" for span in spans) != 1 + ): + raise campaign.CampaignError( + "allocation warmup/measured/cleanup coverage incomplete" + ) + matched = { + row["sequence"]: row + for row in resource_records + if row["backend"] == "embedded" + and row["scale_factor"] == allocation["scale_factor"] + and row["streams"] == allocation["streams"] + } + measured = [span for span in spans if span["phase"] == "resource-validation"] + if [span.get("phase") for span in spans] != ( + ["resource-warmup"] * 22 + ["resource-validation"] * 22 + ["cleanup-probe"] + ): + raise campaign.CampaignError("allocation span phase order differs") + previous_end = -1 + span_identities = set() + for span in spans: + query = campaign.query_uuid(span.get("query_id")) + start, end = span.get("start_ns"), span.get("end_ns") + if ( + query in span_identities + or query in physical_query_ids + or span.get("phase") != "resource-validation" + and query in by_query + or type(start) is not int + or type(end) is not int + or not previous_end <= start < end + or span.get("time_domain") != "unix-epoch-ns" + or span.get("scope") != "runner-lifetime" + ): + raise campaign.CampaignError( + "allocation span identity or interval differs" + ) + span_identities.add(query) + physical_query_ids.add(query) + previous_end = end + if {span.get("sequence") for span in measured} != set(matched) or any( + span.get("query_id") != matched[span["sequence"]]["query_id"] + or any( + span.get(key) + != matched[span["sequence"]].get("resource_span", {}).get(key) + for key in ("start_ns", "end_ns", "time_domain", "scope") + ) + for span in measured + ): + raise campaign.CampaignError( + "allocation profile is not tied to actual resource queries" + ) + before, after = allocation.get("warmed_baseline_bytes", {}), allocation.get( + "post_cleanup_bytes", {} + ) + if before != measured[0].get("before_bytes") or after != spans[-1].get( + "before_bytes" + ): + raise campaign.CampaignError( + "allocation baseline snapshot differs from trace" + ) + if ( + set(before) != {"pinned", "device"} + or set(after) != set(before) + or any( + type(value) is not int or value < 0 + for value in list(before.values()) + list(after.values()) + ) + or sum(max(0, after[kind] - before[kind]) for kind in before) != 0 + ): + raise campaign.CampaignError("physical warmed allocation baseline grew") + for artifact in (lifecycle, sanitizer, baselines): + _record_identity(identity, artifact.get("provenance", {})) + cases = lifecycle.get("cases", []) + if {case.get("id") for case in cases} != LIFECYCLE_CASES or len(cases) != len( + LIFECYCLE_CASES + ): + raise campaign.CampaignError("lifecycle case closure incomplete") + if any( + case.get("passed") is not True + or case.get("retained_input_bytes") != 0 + or case.get("retained_result_bytes") != 0 + or not campaign.legacy._is_sha256(case.get("evidence_sha256")) + or not case.get("barrier") + for case in cases + ): + raise campaign.CampaignError("lifecycle ownership closure failed") + quarantine = next(case for case in cases if case["id"] == "fatal-quarantine") + if ( + quarantine.get("scope") != "test-owned-process-death" + or quarantine.get("admission_sealed") is not True + or quarantine.get("unsafe_owners_retained_before_exit") is not True + or quarantine.get("process_joined") is not True + ): + raise campaign.CampaignError("fatal quarantine process closure unproved") + if ( + type(sanitizer.get("raw_exit_status")) is not int + or sanitizer.get("raw_exit_status") not in (0, 99) + or sanitizer.get("unexplained_errors") != 0 + or sanitizer.get("unexplained_leaks") != 0 + or sanitizer.get("cudf_pinned_pool_reconciled") is not True + or sanitizer.get("cub_diagnostic_reconciled") is not True + or not campaign.legacy._is_sha256(sanitizer.get("evidence_sha256")) + ): + raise campaign.CampaignError("sanitizer/pinned-pool/CUB acceptance unresolved") + hashes = sanitizer.get("raw_evidence_sha256", {}) + raw_runs = sanitizer.get("runs", []) + if ( + not isinstance(hashes, dict) + or len(hashes) not in (2, 4) + or any( + not isinstance(name, str) + or not 0 < len(name) <= 128 + or Path(name).name != name + for name in hashes + ) + or any(not campaign.legacy._is_sha256(value) for value in hashes.values()) + or len(set(hashes.values())) != len(hashes) + or not isinstance(raw_runs, list) + or len(raw_runs) != len(hashes) + or any( + not isinstance(run, dict) or not isinstance(run.get("id"), str) + for run in raw_runs + ) + or {run.get("id") for run in raw_runs} != set(hashes) + ): + raise campaign.CampaignError("raw sanitizer evidence is missing or duplicated") + if sanitizer["raw_exit_status"] == 0: + if any( + any( + type(run.get(key)) is not int or run[key] != 0 + for key in ("exit_status", "raw_error_count", "raw_leak_bytes") + ) + for run in raw_runs + ): + raise campaign.CampaignError( + "raw sanitizer findings contradict zero status" + ) + else: + reconciliations = sanitizer.get("reconciliations", []) + if ( + not isinstance(reconciliations, list) + or len(reconciliations) != 2 + or any( + not isinstance(item, dict) or not isinstance(item.get("id"), str) + for item in reconciliations + ) + ): + raise campaign.CampaignError( + "matched sanitizer controls are missing or duplicated" + ) + reconciled = {item.get("id"): item for item in reconciliations} + if len(reconciliations) != 2 or set(reconciled) != { + "cudf-default-pinned-pool", + "cub-empty-kernel-registration", + }: + raise campaign.CampaignError( + "nonzero sanitizer outcome lacks matched controls" + ) + pinned = reconciled["cudf-default-pinned-pool"] + cub = reconciled["cub-empty-kernel-registration"] + if ( + pinned.get("candidate_bytes") != 41386248 + or pinned.get("control_bytes") != pinned["candidate_bytes"] + or pinned.get("candidate_allocations") != 1 + or pinned.get("control_allocations") != 1 + or pinned.get("initialization_without_gpu_work") is not True + or pinned.get("owner") != "process-global-cudf-default-pinned-resource" + or pinned.get("query_credit_retained_bytes") != 0 + or pinned.get("steady_profile_growth_bytes") != [0] * 6 + or cub.get("candidate_errors") != 1 + or cub.get("control_errors") != cub["candidate_errors"] + or cub.get("control_revision") != "c440013c89f1a54a6e5d934aabd0f9d3ef15ed3c" + or cub.get("symbol") != "_ZN3cub6detail11EmptyKernelIvEEvv" + or cub.get("source_unchanged_from_control") is not True + or not campaign.legacy._is_sha256(cub.get("source_sha256")) + or not campaign.legacy._is_sha256(cub.get("no_op_header_sha256")) + or any( + not campaign.legacy._is_sha256(item.get(key)) + for item in (pinned, cub) + for key in ("candidate_evidence_sha256", "control_evidence_sha256") + ) + ): + raise campaign.CampaignError( + "matched sanitizer baseline reconciliation failed" + ) + classified = { + item[key]: (1, 41386248 if item is pinned else 0) + for item in (pinned, cub) + for key in ("candidate_evidence_sha256", "control_evidence_sha256") + } + if ( + len(classified) != 4 + or set(classified) != set(hashes.values()) + or any( + type(run.get("exit_status")) is not int + or run["exit_status"] != 99 + or type(run.get("raw_error_count")) is not int + or type(run.get("raw_leak_bytes")) is not int + or (run["raw_error_count"], run["raw_leak_bytes"]) + != classified[hashes[run["id"]]] + for run in raw_runs + ) + ): + raise campaign.CampaignError( + "raw sanitizer findings differ from matched controls" + ) + baseline_cases = baselines.get("cases", []) + if {case.get("id") for case in baseline_cases} != set(BASELINE_CASES) or len( + baseline_cases + ) != len(BASELINE_CASES): + raise campaign.CampaignError("matched Native A/B baselines missing") + checks = [] + identities = set() + for case in baseline_cases: + if ( + case.get("id") in identities + or case.get("kind") != BASELINE_CASES.get(case.get("id")) + or not campaign.legacy._is_sha256(case.get("input_sha256")) + or case.get("input_sha256") != case.get("reference_input_sha256") + or case.get("rows") != 262144 + or case.get("timing_boundary_matched") is not True + ): + raise campaign.CampaignError("baseline case identity mismatch") + identities.add(case["id"]) + expected = "native-a" if case["kind"] == "scalar" else "native-b" + if case.get("reference") != expected: + raise campaign.CampaignError("numeric baseline owner mismatch") + revision = { + "native-a": "c440013c89f1a54a6e5d934aabd0f9d3ef15ed3c", + "native-b": "5ea60cd31955d0dced2adcbcd3df0772207b79ef", + }[expected] + if ( + case.get("reference_revision") != revision + or case.get("candidate_revision") != identity["sirius_revision"] + ): + raise campaign.CampaignError("numeric baseline revision mismatch") + samples = case.get("reference_seconds", []) + candidate = case.get("candidate_seconds", []) + if ( + len(samples) < 7 + or len(candidate) < 7 + or any( + type(value) not in (int, float) + or not math.isfinite(value) + or value <= 0 + for value in samples + candidate + ) + ): + raise campaign.CampaignError("numeric raw repetitions missing") + ratio = statistics.median(candidate) / statistics.median(samples) + checks.append( + {"id": case["id"], "ratio": ratio, "limit": 1.1, "passed": ratio <= 1.1} + ) + if not all(check["passed"] for check in checks): + raise campaign.CampaignError("matched numeric regression exceeds 10 percent") + flight_checks = [] + for profile in timing_campaign["flight_profiles"]: + coverage = profile["coverage"] + if not coverage: + continue + for scale in (1, 10): + + def medians(route): + return { + q: statistics.median( + row["wall_seconds"] + for row in timing_records + if row["scale_factor"] == scale + and row["route"] == route + and row["phase"] == "performance" + and row["measured"] + and row["query"] == q + ) + for q in coverage + } + + embedded, flight = medians("embedded-mo"), medians(profile["id"]) + pairs = [ + ( + "sum-covered-query-medians", + sum(embedded.values()), + sum(flight.values()), + ) + ] + + def suite_median(route): + return statistics.median( + sum( + row["wall_seconds"] + for row in timing_records + if row["scale_factor"] == scale + and row["route"] == route + and row["phase"] == "performance" + and row["suite"] == suite + and row["query"] in coverage + ) + for suite in range(1, 6) + ) + + pairs.append( + ( + "median-covered-suite", + suite_median("embedded-mo"), + suite_median(profile["id"]), + ) + ) + if 9 in coverage: + + def q9(route): + return statistics.median( + row["wall_seconds"] + for row in timing_records + if row["scale_factor"] == scale + and row["route"] == route + and row["phase"] == "q9-concurrency" + ) + + pairs.append(("q9-stress-median", q9("embedded-mo"), q9(profile["id"]))) + for metric, numerator, denominator in pairs: + ratio = numerator / denominator + flight_checks.append( + { + "id": profile["id"], + "scale_factor": scale, + "coverage": coverage, + "metric": metric, + "ratio": ratio, + "limit": 1.0, + "passed": ratio <= 1.0, + } + ) + if ratio > 1.0: + raise campaign.CampaignError( + "equivalent Flight comparison exceeds ratio 1.0" + ) + timing_summary["local_acceptance_complete"] = True + timing_summary["remaining_gates"] = ["required-CI", "reviewer-approval"] + timing_summary["numeric_gates"] = checks + timing_summary["flight_gates"] = flight_checks + return timing_summary + + +def checked_artifacts(manifest, paths): + """Check the archive, and parse exactly the consumed bytes that were hashed.""" + root = manifest.parent.resolve() + try: + with manifest.open("rb") as file: + raw = file.read((1 << 20) + 1) + except OSError as error: + raise campaign.CampaignError( + "archive checksum manifest is unreadable" + ) from error + if len(raw) > 1 << 20: + raise campaign.CampaignError("archive checksum manifest exceeds bound") + hashes = json.loads(raw) + if not isinstance(hashes, dict) or not hashes: + raise campaign.CampaignError("archive checksum manifest is empty or invalid") + wanted, data = {path.resolve() for path in paths}, {} + if any(not path.is_relative_to(root) for path in wanted): + raise campaign.CampaignError("acceptance input is outside the archive") + for name, expected in hashes.items(): + if ( + not isinstance(name, str) + or not 0 < len(name) <= 512 + or Path(name).is_absolute() + or Path(name).as_posix() != name + or ".." in Path(name).parts + or not campaign.legacy._is_sha256(expected) + ): + raise campaign.CampaignError("invalid archive checksum entry") + path = root / name + if path.resolve() != path or not path.is_file(): + raise campaign.CampaignError("archive artifact is missing or redirected") + digest = hashlib.sha256() + with path.open("rb") as file: + if path in wanted: + value = file.read() + digest.update(value) + data[path] = value + else: + for chunk in iter(lambda: file.read(64 << 10), b""): + digest.update(chunk) + if digest.hexdigest() != expected: + raise campaign.CampaignError("archive artifact checksum mismatch") + if wanted != set(data): + raise campaign.CampaignError("acceptance input lacks an archive checksum") + return data + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + for name in ( + "timing", + "resource", + "allocations", + "lifecycle", + "sanitizer", + "baselines", + ): + parser.add_argument("--" + name, type=Path, required=True) + parser.add_argument( + "--checksums", type=Path, help="Archive checksums.json (default: timing parent)" + ) + args = parser.parse_args() + paths = [ + args.timing / "campaign.json", + args.timing / "runs.jsonl", + args.resource / "campaign.json", + args.resource / "runs.jsonl", + args.allocations, + args.lifecycle, + args.sanitizer, + args.baselines, + ] + data = checked_artifacts( + args.checksums or args.timing.parent / "checksums.json", paths + ) + + def load(path): + return json.loads(data[path.resolve()]) + + def records(root): + return [ + json.loads(line) + for line in data[(root / "runs.jsonl").resolve()].splitlines() + ] + + result = validate( + load(args.timing / "campaign.json"), + records(args.timing), + load(args.resource / "campaign.json"), + records(args.resource), + load(args.allocations), + load(args.lifecycle), + load(args.sanitizer), + load(args.baselines), + ) + print(json.dumps(result, sort_keys=True, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/optools/sirius_acceptance_test.py b/optools/sirius_acceptance_test.py new file mode 100644 index 0000000000000..cf1b537f7305f --- /dev/null +++ b/optools/sirius_acceptance_test.py @@ -0,0 +1,502 @@ +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import contextlib +import hashlib +import io +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +import sirius_acceptance as acceptance +import sirius_campaign_v2 as campaign +from sirius_campaign_v2_test import provenance + + +def evidence(): + identity = provenance() + timing = campaign.build_campaign(identity) + resource = campaign.build_campaign(identity, resource=True) + + def records(manifest, offset): + result = [] + for spec in manifest["schedule"]: + row = { + **spec, + "query_id": f"{offset+spec['sequence']:032x}", + "wall_seconds": 1.0, + "first_row_seconds": 0.1, + "row_count": 1, + "result_sha256": "a" * 64, + "output_schema_sha256": "b" * 64, + "output_schema": [ + {"oid": 23, "precision": 64, "scale": -1, "nullable": False} + ], + "observations": { + "host_process": {"samples": 2}, + "go_heap": {"samples": 2}, + "cpu": {"value": 1.0}, + "gpu_residency": ( + {"samples": 2} + if spec["backend"] == "embedded" + else {"status": "N/A"} + ), + }, + "execution": None, + "execution_stats": None, + } + if spec["backend"] == "embedded": + row["execution"] = { + "backend": "embedded", + "scan_mode": "mo", + "fallback": False, + "native_capabilities": 31, + "numeric_profile": "mo-exact-decimal-v1", + "gpu_streams": spec["streams"], + "terminal_health": "healthy", + } + row["execution_stats"] = { + "terminal": True, + "fatal": False, + "gpu_tasks_completed": 1, + "mo_input_retained_charged_bytes": 0, + "result_retained_charged_bytes": 0, + "result_rows": 1, + } + row["native_pools"] = { + "source": "sirius-tagged-pools", + "scope": "query-window", + "pools": [ + { + "tier": tier, + "end": {"allocated_bytes": 0, "reserved_bytes": 0}, + } + for tier in ("host", "gpu") + ], + } + if manifest["resource_campaign"]: + row["resource_span"] = { + "time_domain": "unix-epoch-ns", + "scope": "runner-lifetime", + "start_ns": spec["sequence"] * 100, + "end_ns": spec["sequence"] * 100 + 50, + } + result.append(row) + return result + + timing_rows, resource_rows = records(timing, 0), records(resource, 10000) + allocations = [] + for scale in (1, 10): + for stream in (1, 2, 4): + measured = [ + { + **row["resource_span"], + "phase": row["phase"], + "sequence": row["sequence"], + "query_id": row["query_id"], + "before_bytes": {"pinned": 1, "device": 1}, + } + for row in resource_rows + if row["scale_factor"] == scale + and row["streams"] == stream + and row["backend"] == "embedded" + ] + allocations.append( + { + "scale_factor": scale, + "streams": stream, + "provenance": identity, + "source": "nsight-cupti-memory-events", + "scope": "cn-process-lifetime", + "allocation_counts": {"pinned": 1, "device": 1}, + "unexplained_growth_bytes": 0, + "pool_baseline_reconciled": True, + "warmed_baseline_bytes": {"pinned": 1, "device": 1}, + "post_cleanup_bytes": {"pinned": 1, "device": 1}, + "runs": [ + { + "phase": "resource-warmup", + "query_id": f"{100000 + len(allocations) * 1000 + index:032x}", + "sequence": 1000000 + len(allocations) * 1000 + index, + "time_domain": "unix-epoch-ns", + "scope": "runner-lifetime", + "start_ns": measured[0]["start_ns"] - 50 + 2 * index, + "end_ns": measured[0]["start_ns"] - 49 + 2 * index, + "before_bytes": {"pinned": 1, "device": 1}, + } + for index in range(22) + ] + + measured + + [ + { + "phase": "cleanup-probe", + "query_id": f"{200000 + len(allocations) * 1000:032x}", + "sequence": 2000000 + len(allocations) * 1000, + "time_domain": "unix-epoch-ns", + "scope": "runner-lifetime", + "start_ns": measured[-1]["end_ns"] + 1, + "end_ns": measured[-1]["end_ns"] + 2, + "before_bytes": {"pinned": 1, "device": 1}, + } + ], + } + ) + lifecycle = { + "provenance": identity, + "cases": [ + { + "id": case, + "passed": True, + "retained_input_bytes": 0, + "retained_result_bytes": 0, + "evidence_sha256": "c" * 64, + "barrier": "controlled-test-event", + } + for case in acceptance.LIFECYCLE_CASES + ], + } + quarantine = next( + case for case in lifecycle["cases"] if case["id"] == "fatal-quarantine" + ) + quarantine.update( + scope="test-owned-process-death", + admission_sealed=True, + unsafe_owners_retained_before_exit=True, + process_joined=True, + ) + sanitizer = { + "provenance": identity, + "raw_exit_status": 0, + "unexplained_errors": 0, + "unexplained_leaks": 0, + "cudf_pinned_pool_reconciled": True, + "cub_diagnostic_reconciled": True, + "evidence_sha256": "d" * 64, + "raw_evidence_sha256": {"gpu.log": "1" * 64, "abi.log": "2" * 64}, + "runs": [ + {"id": name, "exit_status": 0, "raw_error_count": 0, "raw_leak_bytes": 0} + for name in ("gpu.log", "abi.log") + ], + } + baselines = {"provenance": identity, "cases": []} + for case, kind in acceptance.BASELINE_CASES.items(): + reference, revision = ( + ("native-a", "c440013c89f1a54a6e5d934aabd0f9d3ef15ed3c") + if kind == "scalar" + else ("native-b", "5ea60cd31955d0dced2adcbcd3df0772207b79ef") + ) + baselines["cases"].append( + { + "id": case, + "kind": kind, + "reference": reference, + "reference_revision": revision, + "candidate_revision": identity["sirius_revision"], + "input_sha256": "e" * 64, + "reference_input_sha256": "e" * 64, + "rows": 262144, + "timing_boundary_matched": True, + "reference_seconds": [1.0] * 7, + "candidate_seconds": [1.0] * 7, + } + ) + return [ + timing, + timing_rows, + resource, + resource_rows, + allocations, + lifecycle, + sanitizer, + baselines, + ] + + +class AcceptanceTest(unittest.TestCase): + def test_cli_binds_consumed_inputs_to_archive_checksums(self): + inputs = evidence() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + names = [ + "timing/campaign.json", + "timing/runs.jsonl", + "resource/campaign.json", + "resource/runs.jsonl", + "allocations.json", + "lifecycle.json", + "sanitizer.json", + "baselines.json", + ] + for name, value in zip(names, inputs): + file = root / name + file.parent.mkdir(parents=True, exist_ok=True) + if name.endswith("jsonl"): + file.write_text("".join(json.dumps(row) + "\n" for row in value)) + else: + file.write_text(json.dumps(value)) + hashes = { + name: hashlib.sha256((root / name).read_bytes()).hexdigest() + for name in names + } + manifest = root / "checksums.json" + manifest.write_text(json.dumps(hashes)) + arguments = [ + "sirius_acceptance", + "--timing", + str(root / "timing"), + "--resource", + str(root / "resource"), + ] + for option in ("allocations", "lifecycle", "sanitizer", "baselines"): + arguments += ["--" + option, str(root / (option + ".json"))] + with patch.object(sys, "argv", arguments), contextlib.redirect_stdout( + io.StringIO() + ) as output: + acceptance.main() + self.assertTrue(json.loads(output.getvalue())["local_acceptance_complete"]) + rows = inputs[1] + native = next(row for row in rows if row["route"] == "mo-native") + native["row_count"] = 5 + target = root / "timing/runs.jsonl" + target.write_text("".join(json.dumps(row) + "\n" for row in rows)) + with patch.object(sys, "argv", arguments): + with self.assertRaisesRegex( + campaign.CampaignError, "checksum mismatch" + ): + acceptance.main() + # Rehashing a contradictory archive cannot replace cross-route validation. + hashes["timing/runs.jsonl"] = hashlib.sha256( + target.read_bytes() + ).hexdigest() + manifest.write_text(json.dumps(hashes)) + with patch.object(sys, "argv", arguments): + with self.assertRaisesRegex( + campaign.CampaignError, "cardinality mismatch" + ): + acceptance.main() + del hashes["timing/runs.jsonl"] + manifest.write_text(json.dumps(hashes)) + with patch.object(sys, "argv", arguments): + with self.assertRaisesRegex( + campaign.CampaignError, "lacks an archive checksum" + ): + acceptance.main() + + def test_declared_flight_retained_pairs_use_the_native_oracle(self): + inputs = evidence() + profile = { + "id": "flight-mo", + "coverage": [1], + "provenance": { + "matrixone_revision": "a" * 40, + "sirius_revision": "b" * 40, + "launcher_sha256": "1" * 64, + "configuration_sha256": "2" * 64, + "server_binary_sha256": "3" * 64, + "tn_no_gc_verified": False, + }, + } + manifest = campaign.build_campaign( + inputs[0]["provenance"], flight_profiles=[profile] + ) + original = { + (campaign.legacy._oracle_cell(row), row["route"]): row for row in inputs[1] + } + rows = [] + for spec in manifest["schedule"]: + route = "mo-native" if spec["route"] == "flight-mo" else spec["route"] + row = copy.deepcopy(original[(campaign.legacy._oracle_cell(spec), route)]) + row.update(spec, query_id=f"{50000+spec['sequence']:032x}") + rows.append(row) + campaign.summarize(manifest, rows) + peer = next(row for row in rows if row["route"] == "flight-mo") + peer["row_count"] = 5 + with self.assertRaisesRegex(campaign.CampaignError, "cardinality mismatch"): + campaign.summarize(manifest, rows) + + def test_retained_oracle_pairs_reject_count_schema_and_identity_drift(self): + for records_index in (1, 3): + for mutation in ( + lambda row: row.update(row_count=5), + lambda row: row["output_schema"][0].update(oid=24), + lambda row: row.update(output_schema_sha256="c" * 64), + ): + with self.subTest(records=records_index, mutation=mutation): + inputs = evidence() + native = next( + row + for row in inputs[records_index] + if row["route"] == "mo-native" + ) + mutation(native) + with self.assertRaises(campaign.CampaignError): + acceptance.validate(*inputs) + + def test_retained_pair_does_not_require_equal_ordered_spool_hashes(self): + inputs = evidence() + native = next(row for row in inputs[1] if row["route"] == "mo-native") + native["result_sha256"] = "f" * 64 + self.assertTrue(acceptance.validate(*inputs)["local_acceptance_complete"]) + + def test_matched_sanitizer_controls_preserve_raw_nonzero_status(self): + inputs = evidence() + report = inputs[6] + report.update( + raw_exit_status=99, + raw_evidence_sha256={ + "pinned.log": "a" * 64, + "pinned-control.log": "b" * 64, + "cub.log": "e" * 64, + "cub-control.log": "f" * 64, + }, + runs=[ + { + "id": name, + "exit_status": 99, + "raw_error_count": 1, + "raw_leak_bytes": 41386248 if name.startswith("pinned") else 0, + } + for name in ( + "pinned.log", + "pinned-control.log", + "cub.log", + "cub-control.log", + ) + ], + reconciliations=[ + { + "id": "cudf-default-pinned-pool", + "candidate_bytes": 41386248, + "control_bytes": 41386248, + "candidate_allocations": 1, + "control_allocations": 1, + "initialization_without_gpu_work": True, + "owner": "process-global-cudf-default-pinned-resource", + "query_credit_retained_bytes": 0, + "steady_profile_growth_bytes": [0] * 6, + "candidate_evidence_sha256": "a" * 64, + "control_evidence_sha256": "b" * 64, + }, + { + "id": "cub-empty-kernel-registration", + "candidate_errors": 1, + "control_errors": 1, + "control_revision": "c440013c89f1a54a6e5d934aabd0f9d3ef15ed3c", + "symbol": "_ZN3cub6detail11EmptyKernelIvEEvv", + "source_unchanged_from_control": True, + "source_sha256": "c" * 64, + "no_op_header_sha256": "d" * 64, + "candidate_evidence_sha256": "e" * 64, + "control_evidence_sha256": "f" * 64, + }, + ], + ) + acceptance.validate(*inputs) + self.assertEqual(99, report["raw_exit_status"]) + original = copy.deepcopy(report) + for mutation in ( + lambda r: r["reconciliations"][0].update(candidate_bytes=41386249), + lambda r: r["reconciliations"][0].update(owner="query-owned-leak"), + lambda r: r["runs"][0].update(raw_leak_bytes=41386249), + lambda r: r["runs"][2].update(raw_error_count=2), + lambda r: r["runs"][2].update(exit_status=0), + lambda r: r["runs"].pop(), + lambda r: r["reconciliations"].append( + copy.deepcopy(r["reconciliations"][0]) + ), + ): + inputs[6] = copy.deepcopy(original) + mutation(inputs[6]) + with self.assertRaises(campaign.CampaignError): + acceptance.validate(*inputs) + + def test_allocation_baselines_require_actual_ordered_trace_snapshots(self): + for mutation in ( + lambda a: a["warmed_baseline_bytes"].update(pinned=2), + lambda a: a["post_cleanup_bytes"].update(pinned=0), + lambda a: a["runs"][-1].update(query_id=a["runs"][0]["query_id"]), + lambda a: a["runs"][0].update(end_ns=a["runs"][1]["end_ns"]), + ): + inputs = evidence() + mutation(inputs[4][0]) + with self.assertRaises(campaign.CampaignError): + acceptance.validate(*inputs) + + def test_post_close_pool_proof_requires_the_actual_successor_begin(self): + inputs = evidence() + rows = [row for row in inputs[1] if row["backend"] == "embedded"] + first, following = rows[:2] + first["native_pools"]["window"] = 1 + following["native_pools"]["window"] = 2 + pool = first["native_pools"]["pools"][1] + next_pool = following["native_pools"]["pools"][1] + pool["space"] = next_pool["space"] = 0 + pool["end"]["allocated_bytes"] = 768 + next_pool["begin"] = { + "allocated_bytes": 0, + "reserved_bytes": 0, + "lifetime_peak_bytes": 1024, + } + pool["after_close"] = { + "source": "next-query-window-begin", + "query_id": following["query_id"], + "window": 2, + **next_pool["begin"], + } + acceptance.validate(*inputs) + next_pool["begin"]["allocated_bytes"] = 768 + with self.assertRaisesRegex(campaign.CampaignError, "retains"): + acceptance.validate(*inputs) + + def test_complete_local_evidence_still_requires_ci_and_review(self): + result = acceptance.validate(*evidence()) + self.assertTrue(result["local_acceptance_complete"]) + self.assertFalse(result["acceptance_complete"]) + self.assertEqual( + ["required-CI", "reviewer-approval"], result["remaining_gates"] + ) + + def test_required_evidence_cannot_be_removed_or_misattributed(self): + mutations = [ + lambda x: x[1].pop(), + lambda x: x[3].pop(), + lambda x: x[4].pop(), + lambda x: x[4][0].update(scope="whole-process-lifetime"), + lambda x: x[5]["cases"].pop(), + lambda x: next( + case for case in x[5]["cases"] if case["id"] == "fatal-quarantine" + ).update(process_joined=False), + lambda x: x[6].update(unexplained_leaks=1), + lambda x: x[7]["cases"][0].update(candidate_seconds=[1.11] * 7), + lambda x: x[0]["provenance"].update(sdk_build_mode="development"), + lambda x: x[4][0]["runs"][0].update(query_id="0" * 32), + lambda x: x[4][0]["post_cleanup_bytes"].update(pinned=2), + lambda x: x[7]["cases"][0].update(input_sha256="invalid"), + lambda x: x[7]["cases"].pop(), + lambda x: x[1][0].update(query=22), + ] + for mutation in mutations: + with self.subTest(mutation=mutation): + inputs = evidence() + mutation(inputs) + with self.assertRaises(campaign.CampaignError): + acceptance.validate(*inputs) + + +if __name__ == "__main__": + unittest.main() diff --git a/optools/sirius_campaign.py b/optools/sirius_campaign.py new file mode 100644 index 0000000000000..935896ce5ca40 --- /dev/null +++ b/optools/sirius_campaign.py @@ -0,0 +1,1750 @@ +#!/usr/bin/env python3 +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Own the real services, trusted evidence collector, and v2 campaign worker.""" +from __future__ import annotations + +import argparse +import contextlib +import ctypes +import hashlib +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import sys +import threading +import time +import urllib.request +import uuid +import itertools +import shutil +import re +import tomllib +import csv +import io +import stat + +import sirius_campaign_v2 as v2 + +PROFILE_WORKSPACE_BYTES = 48 << 30 +PROFILE_FINALIZE_SECONDS = 10800 + + +def profile_workspace_bytes(root): + size = files = 0 + for path in root.rglob("*"): + try: + info = path.stat() + except FileNotFoundError: + # The comparator owns and retires temporary runs concurrently. + # A removed file retains no bytes; other stat failures stay fatal. + continue + if not stat.S_ISREG(info.st_mode): + continue + files += 1 + size += info.st_size + if files > 65536 or size > PROFILE_WORKSPACE_BYTES: + raise v2.CampaignError("profile/log workspace quota exceeded") + return size + + +def write_private(path, data): + descriptor = os.open( + path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600 + ) + with os.fdopen(descriptor, "w") as file: + file.write(data) + + +def failure_location(error): + """Report only a repository source line; exception bodies may hold secrets.""" + known = { + "sirius_campaign.py", + "sirius_campaign_v2.py", + "sirius_parity.py", + "sirius_resource.py", + } + location = None + trace = error.__traceback__ + while trace is not None: + name = Path(trace.tb_frame.f_code.co_filename).name + if name in known: + location = {"file": name, "line": trace.tb_lineno} + trace = trace.tb_next + return location + + +def sha256(path): + value = hashlib.sha256() + with Path(path).open("rb") as file: + for chunk in iter(lambda: file.read(1 << 20), b""): + value.update(chunk) + return value.hexdigest() + + +def controller_sha256(): + digest = hashlib.sha256() + for name in ( + "sirius_campaign.py", + "sirius_campaign_v2.py", + "sirius_resource.py", + "sirius_profile_exit.gdb", + ): + digest.update(name.encode() + b"\0") + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest() + + +def pid_identity(pid): + text = Path(f"/proc/{pid}/stat").read_text() + fields = text[text.rfind(")") + 2 :].split() + return int(fields[19]) + + +def launch_target(arguments): + """Record our launch identity before exec; profiler process trees can differ.""" + private_json( + arguments.identity, + {"pid": os.getpid(), "generation": pid_identity(os.getpid())}, + ) + os.execve( + arguments.service, + [str(arguments.service), "-cfg", str(arguments.configuration)], + os.environ.copy(), + ) + + +def wait_target(service, path, timeout=120, monitor=None): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if monitor is not None: + monitor() + if path.exists(): + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(descriptor, "r") as file: + info = os.fstat(file.fileno()) + if ( + not stat.S_ISREG(info.st_mode) + or info.st_mode & 0o777 != 0o600 + or info.st_nlink != 1 + or info.st_size > 4096 + ): + raise v2.CampaignError( + "profile launch identity is not private and bounded" + ) + try: + identity = json.load(file) + except (json.JSONDecodeError, UnicodeDecodeError): + # Creation precedes the small launch record's flush. Retry + # a partial publication within the same bounded deadline. + time.sleep(0.05) + continue + if ( + not isinstance(identity, dict) + or set(identity) != {"pid", "generation"} + or type(identity["pid"]) is not int + or type(identity["generation"]) is not int + or identity["pid"] <= 0 + or pid_identity(identity["pid"]) != identity["generation"] + ): + raise v2.CampaignError("profile launch generation changed") + service.targets[identity["pid"]] = identity["generation"] + return identity["pid"] + if service.process.poll() is not None: + raise v2.CampaignError("profiler exited before target launch") + time.sleep(0.05) + raise v2.CampaignError("profile target launch deadline exceeded") + + +def process_observation(pid): + text = Path(f"/proc/{pid}/stat").read_text() + fields = text[text.rfind(")") + 2 :].split() + cpu = (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK") + rss = int(fields[21]) * os.sysconf("SC_PAGE_SIZE") + return cpu, rss + + +class Sampler: + """Bounded PID observation; RSS and allocator credit remain distinct.""" + + def __init__(self, pid, metric_url, gpu=False): + self.pid, self.generation = pid, pid_identity(pid) + self.metric_url = metric_url + self.gpu = gpu + self.gpu_peak = 0 + self.stop = threading.Event() + self.samples = 0 + self.rss_peak = self.go_peak = 0 + self.error = None + self.start_cpu, self.baseline = process_observation(pid) + self.rss_peak = self.baseline + self.thread = threading.Thread( + target=self._run, name="campaign-pid-observation", daemon=False + ) + + def _sample(self): + if pid_identity(self.pid) != self.generation: + raise v2.CampaignError("service PID generation changed") + _, rss = process_observation(self.pid) + self.rss_peak = max(self.rss_peak, rss) + # Existing Go allocator metrics; never infer native allocation by subtraction. + found = False + with urllib.request.urlopen(self.metric_url, timeout=2) as response: + total = 0 + while True: + raw = response.readline((64 << 10) + 1) + if not raw: + break + total += len(raw) + if len(raw) > 64 << 10 or total > 16 << 20: + raise v2.CampaignError("metrics response exceeds streaming bound") + if raw.startswith(b"go_memstats_heap_alloc_bytes "): + value = int(float(raw.split()[1])) + self.go_peak = max(self.go_peak, value) + found = True + break + if not found: + raise v2.CampaignError("Go allocator observation unavailable") + self.samples += 1 + if self.gpu: + self.gpu_peak = max(self.gpu_peak, self.nvml.used(self.pid)) + + def _run(self): + try: + self.nvml = NVML() if self.gpu else None + try: + self._sample() + while not self.stop.wait(0.1): + self._sample() + self._sample() + finally: + if self.nvml: + self.nvml.close() + except BaseException as error: + self.error = error + + def __enter__(self): + self.thread.start() + return self + + def __exit__(self, exc_type, *_): + self.stop.set() + self.thread.join(5) + if self.thread.is_alive(): + self.error = v2.CampaignError("sampler did not quiesce") + if exc_type is None: + raise self.error + + def result(self): + if self.error is not None: + raise v2.CampaignError( + "required PID/allocator observation failed" + ) from self.error + cpu, final = process_observation(self.pid) + if ( + pid_identity(self.pid) != self.generation + or self.samples == 0 + or self.gpu + and self.gpu_peak == 0 + ): + raise v2.CampaignError("PID observation has no valid samples") + return { + "host_process": { + "source": "proc-stat", + "scope": "cn-process", + "pid": self.pid, + "generation": self.generation, + "units": "bytes", + "interval_seconds": 0.1, + "samples": self.samples, + "peak_bytes": self.rss_peak, + "baseline_bytes": self.baseline, + "final_bytes": final, + }, + "go_heap": { + "source": "go-prometheus", + "scope": "cn-process", + "units": "bytes", + "interval_seconds": 0.1, + "samples": self.samples, + "peak_bytes": self.go_peak, + }, + "cpu": { + "source": "proc-stat", + "scope": "cn-process", + "units": "seconds", + "value": cpu - self.start_cpu, + }, + "gpu_residency": ( + { + "source": "nvml-process-v1", + "scope": "cn-process", + "units": "bytes", + "interval_seconds": 0.1, + "samples": self.samples, + "peak_bytes": self.gpu_peak, + } + if self.gpu + else {"status": "N/A"} + ), + } + + +class NVML: + class Process(ctypes.Structure): + _fields_ = [("pid", ctypes.c_uint), ("used_gpu_memory", ctypes.c_ulonglong)] + + def __init__(self): + self.library = ctypes.CDLL("libnvidia-ml.so.1") + if self.library.nvmlInit_v2() != 0: + raise v2.CampaignError("NVML initialization failed") + self.handle = ctypes.c_void_p() + if ( + self.library.nvmlDeviceGetHandleByIndex_v2(0, ctypes.byref(self.handle)) + != 0 + ): + self.close() + raise v2.CampaignError("NVML GPU unavailable") + + def used(self, pid): + count = ctypes.c_uint(0) + code = self.library.nvmlDeviceGetComputeRunningProcesses( + self.handle, ctypes.byref(count), None + ) + if code not in (0, 7) or count.value > 1024: + raise v2.CampaignError("NVML process enumeration failed") + entries = (self.Process * (count.value + 8))() + count.value = len(entries) + if ( + self.library.nvmlDeviceGetComputeRunningProcesses( + self.handle, ctypes.byref(count), entries + ) + != 0 + ): + raise v2.CampaignError("NVML process observation failed") + matches = [ + entry.used_gpu_memory + for entry in entries[: count.value] + if entry.pid == pid + ] + # Before the first GPU query a live CN can have no CUDA context yet. + # This is an observed zero, followed by a required post-drain sample. + if not matches: + return 0 + if len(matches) != 1 or matches[0] == (1 << 64) - 1: + raise v2.CampaignError("CN GPU residency unavailable") + return matches[0] + + def close(self): + self.library.nvmlShutdown() + + +class EventCollector: + def __init__(self, path, account_id=0): + self.path = Path(path) + self.account_id = account_id + + def checkpoint(self): + return self.path.stat().st_size + + def collect(self, offset, query_id): + identity = v2.query_uuid(query_id) + schema, terminal = [], [] + with self.path.open("rb") as file: + file.seek(offset) + end = self.path.stat().st_size + if end - offset > 64 << 20: + raise v2.CampaignError("per-query log evidence exceeds bound") + while file.tell() < end: + raw = file.readline((1 << 20) + 1) + if len(raw) > 1 << 20: + raise v2.CampaignError("log event exceeds bound") + line = raw.decode("utf-8", errors="replace") + structured = json.loads(line) if line.startswith("{") else None + for marker, target in ( + ("MO campaign result schema ", schema), + ("Sirius embedded execution ", terminal), + ): + if structured is not None: + if structured.get("msg") != marker.rstrip(): + continue + event = structured + else: + if marker not in line: + continue + event = json.loads(line.split(marker, 1)[1]) + if v2.query_uuid(event.get("query_id")) != identity: + continue + if event.get("account_id") != self.account_id: + raise v2.CampaignError("query account attribution mismatch") + target.append(event) + if len(schema) != 1 or len(terminal) > 1: + raise v2.CampaignError( + "missing or duplicate executed-schema/terminal event" + ) + return schema[0], terminal[0] if terminal else None + + +class PoolCollector: + """Existing window tags, attributed by exclusive owned-CN admission order.""" + + pattern = re.compile( + r"\[(host|gpu)_pool\] (?:HOST|GPU):(-?\d+) (QueryBegin|QueryEnd) instance=(0x[0-9a-f]+) connection=(\d+) window=(\d+) query=(\d+) allocated=(\d+) bytes peak=(\d+) bytes (?:free_blocks=(\d+)|reserved=(\d+) bytes)" + ) + + def __init__(self, root): + self.root = Path(root) + + def checkpoint(self): + files = list(self.root.glob("*.log")) + if len(files) > 4: + raise v2.CampaignError("native log file count exceeds bound") + return {path.name: path.stat().st_size for path in files} + + def validate_windows(self, expected): + windows = [] + files = list(self.root.glob("*.log")) + if len(files) > 4: + raise v2.CampaignError("native log file count exceeds bound") + for path in files: + with path.open("rb") as file: + while raw := file.readline((1 << 20) + 1): + if len(raw) > 1 << 20: + raise v2.CampaignError("native log line exceeds bound") + match = re.search( + rb" query (\d+) telemetry_query=[0-9a-f]{32}", raw + ) + if match: + if len(windows) >= 1024: + raise v2.CampaignError("native window ledger exceeds bound") + windows.append(int(match[1])) + if windows != list(range(1, expected + 1)): + raise v2.CampaignError("exclusive CN admission order is ambiguous") + + def collect(self, offsets, query_id, *, pending=False, window_id=None): + windows = [] + entries = [] + files = list(self.root.glob("*.log")) + if len(files) > 4: + raise v2.CampaignError("native log file count exceeds bound") + for path in files: + with path.open("rb") as file: + file.seek(offsets.get(path.name, 0)) + start = file.tell() + end = path.stat().st_size + while file.tell() < end: + if file.tell() - start > 64 << 20: + raise v2.CampaignError("native log segment exceeds bound") + raw = file.readline((1 << 20) + 1) + if len(raw) > 1 << 20: + raise v2.CampaignError("native log line exceeds bound") + line = raw.decode("utf-8", errors="replace") + match = re.search( + r" query (\d+) telemetry_query=([0-9a-f]{32})", line + ) + if match and ( + int(match[1]) == window_id + if window_id is not None + else match[2] == v2.query_uuid(query_id) + ): + windows.append(int(match[1])) + match = self.pattern.search(line) + if match: + if len(entries) >= 128: + raise v2.CampaignError( + "native pool event count exceeds bound" + ) + entries.append(match.groups()) + if ( + windows + and int(match[6]) == windows[0] + and match[1] == "gpu" + and match[3] == "QueryEnd" + ): + break + if len(windows) != 1: + if pending and not windows: + return None + raise v2.CampaignError("native pool query attribution unavailable") + window = windows[0] + pools = {} + owners = set() + for ( + tier, + space, + phase, + instance, + connection, + observed, + ordinal, + allocated, + peak, + free, + reserved, + ) in entries: + if int(observed) != window: + continue + owners.add((instance, connection, ordinal)) + pool = pools.setdefault((tier, int(space)), {}) + if phase in pool: + raise v2.CampaignError("duplicate native pool phase") + pool[phase] = { + "allocated_bytes": int(allocated), + "lifetime_peak_bytes": int(peak), + **( + {"free_blocks": int(free)} + if free is not None + else {"reserved_bytes": int(reserved)} + ), + } + if ( + len(owners) != 1 + or {tier for tier, space in pools} != {"host", "gpu"} + or any( + set(phases) != {"QueryBegin", "QueryEnd"} for phases in pools.values() + ) + ): + if pending and len(owners) <= 1: + return None + raise v2.CampaignError("incomplete native logical-pool evidence") + return { + "source": "sirius-tagged-pools", + "scope": "query-window", + "window": window, + "pools": [ + { + "tier": tier, + "space": space, + "begin": phases["QueryBegin"], + "end": phases["QueryEnd"], + } + for (tier, space), phases in sorted(pools.items()) + ], + } + + +class PublicExecutor: + """Evidence comes from owned service logs, never runner-supplied claims.""" + + def __init__(self, command, config, service): + if pid_identity(service["pid"]) != service["generation"]: + raise v2.CampaignError( + "owned CN generation changed before worker admission" + ) + self.executor = v2.BoundedExecutor(command, config["timeout_seconds"]) + self.collector = EventCollector(service["log"], service.get("account_id", 0)) + self.service = service + self.scratch = Path(config["scratch"]) + self.current_sequence = 0 + self.pool_offsets = {} + self.native_ordinal = 0 + self.pools = ( + PoolCollector(service["native_log_directory"]) + if service.get("native_log_directory") + else None + ) + + def __call__(self, spec): + self.current_sequence = spec["sequence"] + offset = self.collector.checkpoint() + pool_offsets = self.pools.checkpoint() if self.pools else None + started_ns = time.time_ns() + monotonic_start = time.monotonic_ns() + try: + with Sampler( + self.service["pid"], + self.service["metric_url"], + spec["backend"] == "embedded", + ) as sampler: + output = self.executor(spec) + ended_ns = time.time_ns() + if ( + abs((ended_ns - started_ns) - (time.monotonic_ns() - monotonic_start)) + > 10_000_000 + ): + raise v2.CampaignError( + "resource clock correlation drift exceeded bound" + ) + schema, terminal = self.collector.collect(offset, output["query_id"]) + output["output_schema"] = schema["output_schema"] + output["output_schema_sha256"] = schema["output_schema_sha256"] + if spec["backend"] == "embedded" and terminal is None: + raise v2.CampaignError( + "embedded query did not produce terminal GPU evidence" + ) + if spec["backend"] == "native" and terminal is not None: + raise v2.CampaignError("native oracle entered embedded execution") + output["terminal_event"] = terminal + output["observations"] = sampler.result() + if self.pools and spec["backend"] == "embedded": + if len(self.pool_offsets) >= 1024: + raise v2.CampaignError("pool attribution ledger exceeds bound") + self.native_ordinal += 1 + self.pool_offsets[v2.query_uuid(output["query_id"])] = ( + pool_offsets, + self.native_ordinal, + ) + if self.service.get("resource"): + output["resource_span"] = { + "time_domain": "unix-epoch-ns", + "scope": "runner-lifetime", + "start_ns": started_ns, + "end_ns": ended_ns, + } + return output + except BaseException: + # Runner descendants have joined before control reaches this owner. + # A successful descriptor followed by collector failure still owns a + # spool which has not yet transferred to the comparator. + if not self.executor.poisoned: + name = f"run-{spec['sequence']:08d}-{spec['route']}.ndjson" + (self.scratch / name).unlink(missing_ok=True) + raise + + def attach_pools(self, records): + if not self.pools: + return + embedded = [record for record in records if record["backend"] == "embedded"] + if not embedded: + return + last = embedded[-1]["query_id"] + deadline = time.monotonic() + 5 + offsets, window = self.pool_offsets[last] + while self.pools.collect(offsets, last, pending=True, window_id=window) is None: + if time.monotonic() >= deadline: + raise v2.CampaignError("native pool flush deadline exceeded") + time.sleep(0.05) + for record in embedded: + offsets, window = self.pool_offsets[record["query_id"]] + record["native_pools"] = self.pools.collect( + offsets, record["query_id"], window_id=window + ) + record["native_pools"]["attribution"] = "exclusive-cn-admission-order" + record["native_pools"]["public_query_id"] = record["query_id"] + self.pools.validate_windows(self.native_ordinal) + # QueryEnd precedes EmbeddedPrepared::finish's engine/plan destruction. + # Preserve that snapshot. A following window's begin is the existing + # observation after public drain/close and before any new native work. + for previous, following in zip(embedded, embedded[1:]): + before = previous["native_pools"] + after = following["native_pools"] + if after["window"] != before["window"] + 1: + raise v2.CampaignError("native cleanup successor is ambiguous") + for pool in before["pools"]: + if pool["tier"] != "gpu" or not ( + pool["end"]["allocated_bytes"] or pool["end"]["reserved_bytes"] + ): + continue + matches = [ + item + for item in after["pools"] + if (item["tier"], item["space"]) == (pool["tier"], pool["space"]) + ] + if len(matches) != 1: + raise v2.CampaignError("native cleanup pool successor missing") + pool["after_close"] = { + "source": "next-query-window-begin", + "window": after["window"], + "query_id": following["query_id"], + **matches[0]["begin"], + } + self.pool_offsets.clear() + + +class Service: + """A process group plus start generation is the controller's ownership key.""" + + def __init__(self, command, cwd, log, environment): + self.log = Path(log) + descriptor = os.open( + self.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600 + ) + self.file = os.fdopen(descriptor, "wb") + self.process = None + try: + self.process = subprocess.Popen( + command, + cwd=cwd, + env=environment, + stdout=self.file, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + self.generation = pid_identity(self.process.pid) + self.targets = {} + except BaseException: + try: + if self.process is not None: + # Popen's unreaped child owns this fresh process group even + # if publishing its start generation failed. + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait(timeout=5) + finally: + self.file.close() + raise + + def track_target(self, pid): + self.targets[pid] = pid_identity(pid) + + def wait(self, timeout, monitor=None): + if monitor is None: + return self.process.wait(timeout=timeout) + deadline = time.monotonic() + timeout + while True: + monitor() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired("owned service", timeout) + try: + status = self.process.wait(timeout=min(0.5, remaining)) + monitor() + return status + except subprocess.TimeoutExpired: + continue + + def kill_and_wait(self): + for pid, generation in self.targets.items(): + try: + if pid_identity(pid) == generation: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, FileNotFoundError): + pass + if self.process.poll() is None: + if pid_identity(self.process.pid) != self.generation: + raise v2.CampaignError("service identity changed before kill") + try: + os.killpg(self.process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.process.wait(timeout=5) + + def close(self, monitor=None): + try: + if monitor is not None: + monitor() + for pid, generation in self.targets.items(): + try: + if pid_identity(pid) != generation: + # The owned generation has exited; a reused PID belongs + # to someone else and must never receive our signal. + continue + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except FileNotFoundError: + pass + if self.process.poll() is None: + if pid_identity(self.process.pid) != self.generation: + raise v2.CampaignError("service identity changed before close") + if not self.targets: + os.killpg(self.process.pid, signal.SIGTERM) + try: + self.wait( + PROFILE_FINALIZE_SECONDS if self.targets else 30, + monitor, + ) + except subprocess.TimeoutExpired: + raise v2.CampaignError("owned service shutdown deadline exceeded") + if self.targets and self.process.returncode != 0: + raise v2.CampaignError("profile launcher exited unsuccessfully") + for pid, generation in self.targets.items(): + try: + if pid_identity(pid) == generation: + raise v2.CampaignError("profile target did not quiesce") + except (ProcessLookupError, FileNotFoundError): + pass + except BaseException: + # Quota, timeout and cancellation all retain this process owner + # until it is killed and joined. Descendants join at the controller. + self.kill_and_wait() + raise + finally: + self.file.close() + + +def close_services(services, previous_subreaper, monitor=None): + failures = [] + for service in reversed(services): + try: + service.close(monitor=monitor) + except BaseException as error: + failures.append(error) + # The controller entered exclusive ownership before launching any role. + # Its worker's closer still runs first. If that worker died, its escaped + # runner descendants are adopted here and joined after the sibling roles. + try: + v2.legacy._kill_and_reap_adopted_children( + time.monotonic() + v2.legacy.RUNNER_CLEANUP_TIMEOUT_SECONDS + ) + except BaseException as error: + failures.append(error) + if failures: + raise v2.CampaignError("owned service cleanup failed") from failures[0] + v2.legacy._set_child_subreaper(previous_subreaper) + + +def private_json(path, data): + write_private(path, json.dumps(data, sort_keys=True, indent=2) + "\n") + + +def write_timing_tables(output, campaign, records, summary): + buffer = io.StringIO() + fields = ( + "sequence", + "phase", + "scale_factor", + "streams", + "route", + "query", + "suite", + "repetition", + "measured", + "wall_seconds", + "first_row_seconds", + "row_count", + ) + writer = csv.DictWriter(buffer, fieldnames=fields, lineterminator="\n") + writer.writeheader() + for record in records: + writer.writerow({key: record[key] for key in fields}) + write_private(output / "timings.csv", buffer.getvalue()) + if campaign["resource_campaign"]: + return + lines = [ + "Timing measurements (seconds)", + "", + "Warmups and stream controls are excluded from performance medians.", + "", + "| Scale | Query | Native MO median | Embedded GPU median |", + "| --- | --- | ---: | ---: |", + ] + for scale in ("SF1", "SF10"): + routes = summary["scales"][scale] + for query in range(1, 23): + key = f"Q{query}" + lines.append( + f"| {scale} | {key} | {routes['mo-native']['query_medians'][key]:.6f} | {routes['embedded-mo']['query_medians'][key]:.6f} |" + ) + for metric, label in ( + ("sum_query_medians", "Sum of query medians"), + ("median_full_suite", "Median full suite"), + ("q9_stress_median", "Q9 repetition median"), + ): + lines.append( + f"| {scale} | {label} | {routes['mo-native'][metric]:.6f} | {routes['embedded-mo'][metric]:.6f} |" + ) + write_private(output / "timings.md", "\n".join(lines) + "\n") + + +def native_configuration(workspace): + return f"""sirius: + space: + gpu: + - device_id: 0 + memory_capacity: {6 << 30} + host: + - numa_id: -1 + memory_capacity: {16 << 30} + telemetry: + output_directory: {json.dumps(str(workspace/'native-telemetry'))} +""" + + +def make_configs(workspace, native_config, stream, port_base): + """Separate roles permit CN profile restarts without a dataset reload.""" + data = workspace / "data" + common = f"""data-dir = {json.dumps(str(data))} +[log] +level = "info" +[[fileservice]] +name = "LOCAL" +backend = "DISK-V2" +data-dir = {json.dumps(str(data/'local'))} +[[fileservice]] +name = "SHARED" +backend = "DISK-V2" +data-dir = {json.dumps(str(data/'shared'))} +[hakeeper-client] +service-addresses = ["127.0.0.1:{port_base}"] +""" + log_id = str(uuid.uuid4()) + log = f"""service-type = "LOG" +{common} +[logservice] +uuid = "{log_id}" +logservice-address = "127.0.0.1:{port_base}" +logservice-listen-address = "127.0.0.1:{port_base}" +raft-address = "127.0.0.1:{port_base+1}" +raft-listen-address = "127.0.0.1:{port_base+1}" +gossip-address = "127.0.0.1:{port_base+2}" +gossip-listen-address = "127.0.0.1:{port_base+2}" +gossip-seed-addresses = ["127.0.0.1:{port_base+2}"] +[logservice.BootstrapConfig] +bootstrap-cluster = true +num-of-log-shards = 1 +num-of-tn-shards = 1 +num-of-log-shard-replicas = 1 +init-hakeeper-members = ["131072:{log_id}"] +""" + tn = f"""service-type = "TN" +{common} +[tn] +uuid = "{uuid.uuid4()}" +port-base = {port_base+100} +[observability] +disableMetric = true +disableTrace = true +""" + cn = f"""service-type = "CN" +{common} +[cn] +uuid = "{uuid.uuid4()}" +port-base = {port_base+200} +[cn.frontend] +host = "127.0.0.1" +port = {port_base+300} +processLimitationSize = 34359738368 +[cn.sirius] +enabled = true +backend = "embedded" +input-mode = "mo" +native-config-path = {json.dumps(str(native_config))} +gpu-streams = {stream} +max-waiting-queries = 16 +cleanup-timeout = "30s" +request-timeout = "1h" +[observability] +disableMetric = false +enable-metric-to-prom = true +status-host = "127.0.0.1" +status-port = {port_base+301} +disableTrace = true +""" + output = {} + for role, contents in (("log", log), ("tn", tn), ("cn", cn)): + path = workspace / f"{role}-{stream}.toml" + write_private(path, contents) + output[role] = path + return output + + +def wait_ready(service, port, timeout=120, monitor=None): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if monitor is not None: + monitor() + if service.process.poll() is not None: + raise v2.CampaignError("service exited before readiness") + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + # Bounded readiness polling; this does not order a test phase. + time.sleep(0.1) + raise v2.CampaignError("service readiness deadline exceeded") + + +def run_worker(config_path): + config = json.loads(Path(config_path).read_text()) + runner_cfg = config["runner_configuration"] + os.environ["MO_SIRIUS_CAMPAIGN_CONFIG"] = runner_cfg + campaign = config["campaign"] + scratch = v2.Scratch(config["scratch"]) + executor = PublicExecutor( + [config["runner"]], json.loads(Path(runner_cfg).read_text()), config["service"] + ) + try: + warmups = [] + if config.get("warmup_campaign"): + warmups = v2.execute_campaign(config["warmup_campaign"], executor, scratch) + scratch = v2.Scratch(config["scratch"]) + records = v2.execute_campaign(campaign, executor, scratch) + probes = [] + if config.get("probe_campaign"): + scratch = v2.Scratch(config["scratch"]) + probes = v2.execute_campaign(config["probe_campaign"], executor, scratch) + executor.attach_pools(warmups + records + probes) + private_json( + Path(config["records"]), + { + "partial": True, + "schedule_sha256": hashlib.sha256( + v2.canonical(campaign["schedule"]) + ).hexdigest(), + "records": records, + "excluded_warmups": warmups, + "cleanup_probes": probes, + }, + ) + except BaseException as error: + private_json( + Path(config["records"]), + { + "partial": True, + "complete": False, + "category": "worker-failure", + "sequence": executor.current_sequence, + "location": failure_location(error), + }, + ) + raise + finally: + scratch.close() + + +def resource_phase_selection(resource, phases): + if phases is None: + return () + if ( + not resource + or not phases + or len(phases) > 6 + or any(type(phase) is not int or not 1 <= phase <= 6 for phase in phases) + or len(set(phases)) != len(phases) + ): + raise v2.CampaignError("diagnostic phases require unique resource phases 1..6") + return tuple(sorted(phases)) + + +def resource_diagnostic_summary(campaign, records, selected): + """Validate selected cells without creating a full-campaign verdict.""" + selected = resource_phase_selection(campaign["resource_campaign"], selected) + expected = [ + spec + for number, (_, specs) in enumerate( + itertools.groupby( + campaign["schedule"], + lambda spec: (spec["scale_factor"], spec["streams"]), + ), + 1, + ) + for spec in specs + if number in selected + ] + if len(records) != len(expected): + raise v2.CampaignError("diagnostic requires all selected query records") + identities = set() + for spec, record in zip(expected, records): + v2.validate_record(spec, record) + identity = v2.query_uuid(record["query_id"]) + if identity in identities: + raise v2.CampaignError("diagnostic contains duplicate query identity") + identities.add(identity) + return { + "schema_version": 2, + "diagnostic": True, + "partial": True, + "complete": False, + "resource_phases": list(selected), + "validated": len(records), + "schedule_sha256": hashlib.sha256(v2.canonical(expected)).hexdigest(), + "provenance": campaign["provenance"], + } + + +def run_complete(arguments, *, provenance=None): + """Only the controller can assemble the immutable complete verdict.""" + selected = resource_phase_selection( + arguments.resource, getattr(arguments, "resource_phases", None) + ) + workspace = arguments.workspace.resolve() + output = arguments.output.resolve() + if output.exists() and any(output.iterdir()): + raise v2.CampaignError("output directory must be empty") + output.mkdir(mode=0o700, parents=True, exist_ok=True) + provenance = ( + json.loads(arguments.provenance.read_text()) + if provenance is None + else provenance + ) + if provenance.get("service_binary_sha256") != sha256( + arguments.service + ) or provenance.get("runner_binary_sha256") != sha256(arguments.runner): + raise v2.CampaignError("campaign executable identity mismatch") + if provenance.get("controller_sha256") != controller_sha256(): + raise v2.CampaignError("campaign collector/comparator identity mismatch") + base_runner = json.loads((workspace / "runner.json").read_text()) + expected_session = [ + "set session mo_sirius_campaign_evidence=1", + "set session div_precision_increment=4", + "set time_zone='+00:00'", + ] + if base_runner.get("session") != expected_session: + raise v2.CampaignError("live campaign session differs from frozen provenance") + sirius = tomllib.loads((workspace / "cn-2.toml").read_text())["cn"]["sirius"] + if any( + sirius.get(key) != value + for key, value in { + "enabled": True, + "backend": "embedded", + "input-mode": "mo", + "max-waiting-queries": 16, + "cleanup-timeout": "30s", + }.items() + ) or Path(sirius["native-config-path"]).read_text() != native_configuration( + workspace + ): + raise v2.CampaignError( + "live campaign configuration differs from frozen profile" + ) + campaign = v2.build_campaign( + provenance, + resource=arguments.resource, + flight_profiles=[ + {"id": route, "coverage": [], "status": "N/A", "reason": "not-provisioned"} + for route in ("flight-mo", "flight-tae") + ], + ) + environment = os.environ.copy() + environment["GOMAXPROCS"] = "4" + run_workspace = workspace / f"campaign-{uuid.uuid4().hex}" + run_workspace.mkdir(mode=0o700) + (run_workspace / "scratch").mkdir(mode=0o700) + services = [] + cn = None + records = [] + attempted = 0 + failed_sequence = None + worker_location = None + stage = "service-startup" + profile_workspace_peak = 0 + + def check_workspace(): + nonlocal profile_workspace_peak + profile_workspace_peak = max( + profile_workspace_peak, profile_workspace_bytes(run_workspace) + ) + + previous_subreaper = v2.legacy._enter_exclusive_subreaper() + try: + for role in ("log", "tn"): + configs = workspace / f"{role}-2.toml" + service = Service( + [str(arguments.service.resolve()), "-cfg", str(configs)], + workspace, + run_workspace / f"{role}.log", + environment, + ) + services.append(service) + # Group only contiguous configurations; preserve the original schedule. + for phase_number, ((scale, stream), specs) in enumerate( + itertools.groupby( + campaign["schedule"], + lambda spec: (spec["scale_factor"], spec["streams"]), + ), + 1, + ): + if selected and phase_number not in selected: + continue + if cn is not None: + cn.close(monitor=check_workspace) + services.remove(cn) + cn = None + cn_template = (workspace / "cn-2.toml").read_text() + cn_template = cn_template.replace( + "gpu-streams = 2", f"gpu-streams = {stream}" + ) + timeout = 10800 if arguments.resource else 3600 + if re.search(r"(?m)^request-timeout\s*=", cn_template): + cn_template = re.sub( + r"(?m)^request-timeout\s*=.*$", + f'request-timeout = "{timeout}s"', + cn_template, + ) + else: + cn_template = cn_template.replace( + "[cn.sirius]", f'[cn.sirius]\nrequest-timeout = "{timeout}s"' + ) + phase_workspace = run_workspace / f"phase-{phase_number}" + phase_workspace.mkdir(mode=0o700) + (phase_workspace / "nsys-temp").mkdir(mode=0o700) + path = phase_workspace / "cn.toml" + write_private(path, cn_template) + log = phase_workspace / "cn.log" + command = [str(arguments.service.resolve()), "-cfg", str(path)] + cn_environment = environment.copy() + cn_environment.update( + SIRIUS_LOG_BACKEND="spdlog", + SIRIUS_LOG_LEVEL="info", + SIRIUS_LOG_DIR=str(phase_workspace / "native"), + NSYS_TMPDIR=str(phase_workspace / "nsys-temp"), + ) + if arguments.resource: + prefix = phase_workspace / "resource" + identity_path = phase_workspace / "target.json" + command = [ + sys.executable, + str(Path(__file__).resolve()), + "target", + "--service", + str(arguments.service.resolve()), + "--configuration", + str(path), + "--identity", + str(identity_path), + ] + command = [ + "gdb", + "--batch", + "-x", + str(Path(__file__).with_name("sirius_profile_exit.gdb")), + "--args", + *command, + ] + command = [ + "nsys", + "profile", + "--trace=cuda,nvtx", + "--sample=none", + "--cpuctxsw=none", + "--cuda-memory-usage=true", + "--cuda-trace-all-apis=false", + "--cuda-event-trace=false", + "--cuda-flush-interval=100", + "--discard-environment=true", + "--duration=10800", + "--kill=none", + "--wait=primary", + "--force-overwrite=false", + "--output", + str(prefix), + *command, + ] + cn = Service(command, workspace, log, cn_environment) + services.append(cn) + stage = "profile-launch" if arguments.resource else "cn-readiness" + pid = ( + wait_target(cn, identity_path, monitor=check_workspace) + if arguments.resource + else cn.process.pid + ) + stage = "cn-readiness" + wait_ready( + cn, + arguments.port_base + 300, + timeout=300 if arguments.resource else 120, + monitor=check_workspace, + ) + phase = list(specs) + runner_cfg = json.loads((workspace / "runner.json").read_text()) + runner_cfg["scratch"] = str(run_workspace / "scratch") + runner_cfg["timeout_seconds"] = 10800 if arguments.resource else 3600 + runner_path = phase_workspace / "runner.json" + private_json(runner_path, runner_cfg) + partial = {**campaign, "schedule": phase} + records_path = phase_workspace / "records.json" + worker_cfg = { + "runner_configuration": str(runner_path), + "runner": str(arguments.runner.resolve()), + "scratch": runner_cfg["scratch"], + "service": { + "pid": pid, + "generation": pid_identity(pid), + "log": str(log), + "metric_url": f"http://127.0.0.1:{arguments.port_base+301}/metrics", + }, + "campaign": partial, + "records": str(records_path), + } + worker_cfg["service"]["native_log_directory"] = str( + phase_workspace / "native" + ) + if arguments.resource: + worker_cfg["service"]["resource"] = True + + def excluded(specs, phase_name, base): + return { + **campaign, + "schedule": [ + { + **spec, + "sequence": base + i, + "phase": phase_name, + "measured": False, + } + for i, spec in enumerate(specs) + ], + } + + worker_cfg["warmup_campaign"] = excluded( + phase, "resource-warmup", 1_000_000 + phase_number * 1000 + ) + worker_cfg["probe_campaign"] = excluded( + phase[:2], "cleanup-probe", 2_000_000 + phase_number * 1000 + ) + worker_path = phase_workspace / "worker.json" + private_json(worker_path, worker_cfg) + worker_environment = environment.copy() + stage = "public-worker" + worker = Service( + [ + sys.executable, + str(Path(__file__).resolve()), + "worker", + str(worker_path), + ], + workspace, + phase_workspace / "worker.log", + worker_environment, + ) + try: + deadline = ( + time.monotonic() + + (2 * len(phase) + 2) * runner_cfg["timeout_seconds"] + + 30 + ) + while worker.process.poll() is None: + # Bound all private trace/log material independently of row scratch. + check_workspace() + if any(service.process.poll() is not None for service in services): + raise v2.CampaignError("owned CN exited during campaign") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise v2.CampaignError("worker phase deadline exceeded") + try: + worker.process.wait(timeout=min(0.5, remaining)) + except subprocess.TimeoutExpired: + continue + status = worker.process.returncode + if status != 0: + if records_path.exists() and records_path.stat().st_size <= 65536: + failure = json.loads(records_path.read_text()) + sequence = failure.get("sequence") + if type(sequence) is int and any( + spec["sequence"] == sequence + for spec in phase + + worker_cfg.get("warmup_campaign", {}).get("schedule", []) + + worker_cfg.get("probe_campaign", {}).get("schedule", []) + ): + failed_sequence = sequence + location = failure.get("location") + if ( + isinstance(location, dict) + and set(location) == {"file", "line"} + and location["file"] + in { + "sirius_campaign.py", + "sirius_campaign_v2.py", + "sirius_parity.py", + "sirius_resource.py", + } + and type(location["line"]) is int + and 0 < location["line"] < 10000 + ): + worker_location = location + raise v2.CampaignError("public campaign worker failed") + finally: + worker.close(monitor=check_workspace) + envelope = json.loads(records_path.read_text()) + if ( + envelope.get("partial") is not True + or envelope.get("schedule_sha256") + != hashlib.sha256(v2.canonical(phase)).hexdigest() + ): + raise v2.CampaignError("worker phase identity mismatch") + records.extend(envelope["records"]) + attempted += len(phase) + if arguments.resource: + stage = "profile-export" + cn.close(monitor=check_workspace) + services.remove(cn) + cn = None + check_workspace() + exported = phase_workspace / "resource.sqlite" + exporter = Service( + [ + "nsys", + "export", + "--type=sqlite", + "--ts-normalize=true", + "--output", + str(exported), + str(prefix) + ".nsys-rep", + ], + workspace, + phase_workspace / "export.log", + environment, + ) + services.append(exporter) + try: + if exporter.wait(PROFILE_FINALIZE_SECONDS, check_workspace) != 0: + raise v2.CampaignError("owned profile export failed") + finally: + exporter.close(monitor=check_workspace) + services.remove(exporter) + from sirius_resource import ( + allocation_summary, + MAX_LEDGER_BYTES, + MAX_SORT_FILE_BYTES, + ) + + check_workspace() + if profile_workspace_bytes(run_workspace) > ( + PROFILE_WORKSPACE_BYTES - MAX_LEDGER_BYTES - 2 * MAX_SORT_FILE_BYTES + ): + raise v2.CampaignError( + "profile workspace lacks allocation ledger capacity" + ) + + all_runs = ( + envelope["excluded_warmups"] + + envelope["records"] + + envelope["cleanup_probes"] + ) + stage = "allocation-parse" + allocation = allocation_summary(exported, pid, all_runs) + measured = [ + span + for span in allocation["runs"] + if span["phase"] == "resource-validation" + ] + final = [ + span + for span in allocation["runs"] + if span["phase"] == "cleanup-probe" + ] + if len(measured) != 22 or len(final) != 1: + raise v2.CampaignError("resource profile phase coverage differs") + baseline = measured[0]["before_bytes"] + after = final[0]["before_bytes"] + growth = sum(max(0, after[kind] - baseline[kind]) for kind in baseline) + tagged = [ + run["native_pools"] + for run in envelope["records"] + if run["backend"] == "embedded" + ] + logical_gpu_clean = all( + pool.get("after_close", pool["end"])["allocated_bytes"] == 0 + and pool.get("after_close", pool["end"])["reserved_bytes"] == 0 + for entry in tagged + for pool in entry["pools"] + if pool["tier"] == "gpu" + ) + allocation.update( + scale_factor=scale, + streams=stream, + provenance=provenance, + warmed_baseline_bytes=baseline, + post_cleanup_bytes=after, + unexplained_growth_bytes=growth, + pool_baseline_reconciled=growth == 0 and logical_gpu_clean, + logical_pool_scope="query-window", + physical_baseline_scope="idle-cn-process", + ) + private_json(output / f"resource-phase-{phase_number}.json", allocation) + private_json( + output / f"excluded-phase-{phase_number}.json", + { + "warmups": envelope["excluded_warmups"], + "cleanup_probes": envelope["cleanup_probes"], + }, + ) + private_json( + output / f"query-records-phase-{phase_number}.json", envelope + ) + # Worker, CN, profiler, export and ledger owners have joined. + # Safe evidence survives; old raw phases cannot accumulate. + shutil.rmtree(phase_workspace) + stage = "summary" + summary = ( + resource_diagnostic_summary(campaign, records, selected) + if selected + else v2.summarize(campaign, records) + ) + except BaseException as error: + diagnostic = { + "schema_version": 2, + "category": "campaign-failure", + "stage": stage, + "attempted": attempted, + "validated": len(records), + "complete": False, + "location": worker_location or failure_location(error), + "profile_workspace_peak_bytes": profile_workspace_peak, + } + if failed_sequence is not None: + diagnostic["sequence"] = failed_sequence + target = output / "failure.json" + if not target.exists(): + private_json(target, diagnostic) + raise + finally: + try: + close_services(services, previous_subreaper, monitor=check_workspace) + except BaseException as error: + target = output / "failure.json" + if not target.exists(): + private_json( + target, + { + "schema_version": 2, + "category": "cleanup-failure", + "stage": "service-shutdown", + "complete": False, + "location": failure_location(error), + }, + ) + raise + shutil.rmtree(run_workspace) + # Full evidence is publishable only after all owned processes quiesce. + if selected: + private_json(output / "resource-diagnostic.json", summary) + else: + private_json(output / "campaign.json", campaign) + private_json(output / "summary.json", summary) + write_timing_tables(output, campaign, records, summary) + path = output / ("diagnostic-runs.jsonl" if selected else "runs.jsonl") + descriptor = os.open( + path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600 + ) + with os.fdopen(descriptor, "w") as file: + for record in records: + file.write(v2.canonical(record).decode() + "\n") + + +def start_cluster(arguments): + workspace = arguments.workspace.resolve() + workspace.mkdir(mode=0o700, parents=True) + native = workspace / "sirius.yaml" + write_private(native, native_configuration(workspace)) + configs = make_configs(workspace, native, 2, arguments.port_base) + environment = os.environ.copy() + environment["GOMAXPROCS"] = "4" + services = [] + previous_subreaper = v2.legacy._enter_exclusive_subreaper() + try: + for role in ("log", "tn", "cn"): + service = Service( + [str(arguments.service.resolve()), "-cfg", str(configs[role])], + workspace, + workspace / f"{role}.log", + environment, + ) + services.append(service) + wait_ready(services[-1], arguments.port_base + 300) + cfg = { + "dsn": f"dump:111@tcp(127.0.0.1:{arguments.port_base+300})/", + "query_directory": str( + arguments.source_root.resolve() / "pkg/sql/plan/tpch" + ), + "scratch": str(workspace / "scratch"), + "databases": {"1": "tpch_sf1", "10": "tpch_sf10"}, + "session": [ + "set session mo_sirius_campaign_evidence=1", + "set session div_precision_increment=4", + "set time_zone='+00:00'", + ], + "timeout_seconds": 3600, + } + (workspace / "scratch").mkdir(mode=0o700) + private_json(workspace / "runner.json", cfg) + private_json( + workspace / "services.json", + { + "pid": services[-1].process.pid, + "generation": services[-1].generation, + "log": str(services[-1].log), + "metric_url": f"http://127.0.0.1:{arguments.port_base+301}/metrics", + }, + ) + while True: + for service in services: + if service.process.poll() is not None: + raise v2.CampaignError("owned service exited") + time.sleep(0.5) + finally: + close_services(services, previous_subreaper) + + +def freeze_provenance(arguments): + """Capture actual binaries, guarded SDK, source data and local toolchain.""" + import sirius_sdk + + root = arguments.source_root.resolve() + packaged = json.loads((root / "lib/sirius-provenance.json").read_text()) + if packaged["binary_sha256"] != sha256(arguments.service): + raise v2.CampaignError("service binary differs from its packaged SDK proof") + sdk = Path(packaged["sdk"]) + sirius_sdk.validate(sdk, packaged["mode"], packaged["merged_ref"], root) + if sha256(sdk / "link.json") != packaged["sdk_manifest_sha256"]: + raise v2.CampaignError("packaged SDK manifest differs from the live closure") + + def command(*args): + value = subprocess.check_output( + args, timeout=30, stderr=subprocess.DEVNULL, text=True + ).strip() + if len(value) > 65536: + raise v2.CampaignError("toolchain observation exceeds bound") + return value + + build = command("go", "version", "-m", str(arguments.service.resolve())) + revision = re.search(r"vcs.revision=([0-9a-f]{40})", build) + if revision is None or revision[1] != command( + "git", "-C", str(root), "rev-parse", "HEAD" + ): + raise v2.CampaignError( + "service binary does not match the current MO source revision" + ) + gpu = command( + "nvidia-smi", + "--query-gpu=name,uuid,memory.total,driver_version", + "--format=csv,noheader,nounits", + ).splitlines() + if len(gpu) != 1: + raise v2.CampaignError("campaign requires one explicitly selected GPU") + name, gpu_uuid, memory, driver = [value.strip() for value in gpu[0].split(",")] + cpu = next( + line.split(":", 1)[1].strip() + for line in Path("/proc/cpuinfo").read_text().splitlines() + if line.startswith("model name") + ) + datasets = {} + for scale, loaded_path in zip((1, 10), arguments.loaded): + manifest = json.loads( + (arguments.datasets / f"SF{scale}" / "manifest.json").read_text() + ) + loaded = json.loads(loaded_path.read_text()) + if ( + loaded.get("scale_factor") != scale + or loaded.get("dataset_sha256") != manifest["dataset_sha256"] + ): + raise v2.CampaignError("loaded dataset differs from its source manifest") + for table, source in manifest["tables"].items(): + actual = loaded.get("tables", {}).get(table, {}) + if ( + actual.get("row_count") != source["row_count"] + or actual.get("source_sha256") != source["sha256"] + or not v2.legacy._is_sha256(actual.get("loaded_data_sha256")) + ): + raise v2.CampaignError("loaded table verification is incomplete") + datasets[f"SF{scale}"] = manifest["dataset_sha256"] + prefix = Path(packaged["pixi_provider"]["prefix"]) + provenance = { + "matrixone_revision": revision[1], + "sirius_revision": packaged["source_revision"], + "sdk_sha256": packaged["sdk_manifest_sha256"], + "sdk_build_mode": packaged["mode"], + "service_binary_sha256": sha256(arguments.service), + "runner_binary_sha256": sha256(arguments.runner), + "controller_sha256": controller_sha256(), + "datasets": datasets, + "query_sha256": { + f"Q{q}": sha256(root / f"pkg/sql/plan/tpch/q{q}.sql") for q in range(1, 23) + }, + "hardware": { + "gpu_name": name, + "gpu_uuid": gpu_uuid, + "gpu_total_bytes": int(memory) << 20, + "cpu_model": cpu, + "cpu_count": os.cpu_count(), + "host_total_bytes": os.sysconf("SC_PHYS_PAGES") + * os.sysconf("SC_PAGE_SIZE"), + }, + "toolchain": { + "go": build.splitlines()[0].rsplit(": ", 1)[-1], + "cxx": command(packaged["compiler"], "-dumpfullversion"), + "cuda": command(str(prefix / "bin/nvcc"), "--version").splitlines()[-1], + "driver": driver, + "pixi_lock_sha256": packaged["pixi_provider"]["lock_sha256"], + "nsight": command("nsys", "--version"), + "gdb": command("gdb", "--version").splitlines()[0], + }, + "config": { + "gpu_memory_bytes": 6 << 30, + "native_host_memory_bytes": 16 << 30, + "input_window_bytes": 64 << 20, + "result_window_bytes": 64 << 20, + "active_queries": 1, + "waiting_queries": 16, + "cleanup_timeout_seconds": 30, + "query_timeout_seconds": 3600, + "resource_timeout_seconds": 10800, + "session": { + "campaign_evidence": True, + "div_precision_increment": 4, + "time_zone": "+00:00", + }, + }, + } + v2.build_campaign(provenance) + private_json(arguments.output, provenance) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + worker = sub.add_parser("worker") + worker.add_argument("config", type=Path) + target = sub.add_parser("target") + target.add_argument("--service", type=Path, required=True) + target.add_argument("--configuration", type=Path, required=True) + target.add_argument("--identity", type=Path, required=True) + serve = sub.add_parser("serve") + serve.add_argument("--workspace", type=Path, required=True) + serve.add_argument("--service", type=Path, required=True) + serve.add_argument("--source-root", type=Path, required=True) + serve.add_argument("--port-base", type=int, default=36001) + run = sub.add_parser("run") + run.add_argument("--workspace", type=Path, required=True) + run.add_argument("--service", type=Path, required=True) + run.add_argument("--runner", type=Path, required=True) + run.add_argument("--provenance", type=Path, required=True) + run.add_argument("--output", type=Path, required=True) + run.add_argument("--resource", action="store_true") + run.add_argument( + "--resource-phases", + type=int, + choices=range(1, 7), + nargs="+", + help="diagnostic only: selected resource phases; never publishes acceptance", + ) + run.add_argument("--port-base", type=int, default=36001) + freeze = sub.add_parser("freeze") + freeze.add_argument("--source-root", type=Path, required=True) + freeze.add_argument("--service", type=Path, required=True) + freeze.add_argument("--runner", type=Path, required=True) + freeze.add_argument("--datasets", type=Path, required=True) + freeze.add_argument("--loaded", type=Path, nargs=2, required=True) + freeze.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + + def interrupted(*_): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, interrupted) + try: + if args.command == "worker": + run_worker(args.config) + elif args.command == "target": + launch_target(args) + elif args.command == "serve": + start_cluster(args) + elif args.command == "freeze": + freeze_provenance(args) + else: + run_complete(args) + except (KeyboardInterrupt, v2.CampaignError): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/optools/sirius_campaign_test.py b/optools/sirius_campaign_test.py new file mode 100644 index 0000000000000..ea0fa3b720c05 --- /dev/null +++ b/optools/sirius_campaign_test.py @@ -0,0 +1,745 @@ +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import contextlib +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import select +import signal +import sqlite3 +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch, MagicMock + +import sirius_campaign as controller +import sirius_campaign_v2 as v2 +import sirius_resource as resource +from sirius_campaign_v2_test import provenance, output + + +class ControllerTest(unittest.TestCase): + def test_service_generation_failure_retains_spawn_cleanup(self): + with tempfile.TemporaryDirectory() as directory: + child = MagicMock(pid=123) + with ( + patch.object( + controller.subprocess, "Popen", return_value=child + ) as spawn, + patch.object( + controller, + "pid_identity", + side_effect=v2.CampaignError("generation"), + ), + patch.object(controller.os, "killpg") as kill, + ): + with self.assertRaisesRegex(v2.CampaignError, "generation"): + controller.Service( + ["fixture"], directory, Path(directory) / "log", {} + ) + kill.assert_called_once_with(123, signal.SIGKILL) + child.wait.assert_called_once_with(timeout=5) + self.assertTrue(spawn.call_args.kwargs["stdout"].closed) + + def test_live_profile_writers_are_cancelled_and_joined_on_quota(self): + for stage in ("finalize", "export"): + with self.subTest(stage=stage): + self._profile_writer_probe(stage, 1 << 20, expect_quota=True) + + def test_small_export_reaches_parser_after_joined_writer(self): + self._profile_writer_probe("export", 1024, expect_quota=False) + + def _profile_writer_probe(self, stage, size, *, expect_quota): + from sirius_acceptance_test import evidence + + script = """import os,signal,sys +path,size,mode=sys.argv[1],int(sys.argv[2]),sys.argv[3] +def write(*_): + with open(path,'wb') as f: f.write(b'x'*size) + print('written',flush=True) +signal.signal(signal.SIGTERM,write if mode=='finalize' else lambda *_:sys.exit(0)) +print('ready',flush=True) +if mode=='export': + sys.stdin.buffer.read(1) + write() + if size==1024: sys.exit(0) +while True: signal.pause() +""" + service_type = controller.Service + children, legacy_calls = [], [] + with ( + tempfile.TemporaryDirectory() as directory, + contextlib.ExitStack() as stack, + ): + root = Path(directory) + workspace = root / "workspace" + workspace.mkdir() + service_file, runner_file = root / "service", root / "runner" + service_file.write_text("service") + runner_file.write_text("runner") + identity = provenance() + identity["service_binary_sha256"] = controller.sha256(service_file) + identity["runner_binary_sha256"] = controller.sha256(runner_file) + cfg = { + "session": [ + "set session mo_sirius_campaign_evidence=1", + "set session div_precision_increment=4", + "set time_zone='+00:00'", + ] + } + (workspace / "runner.json").write_text(json.dumps(cfg)) + (workspace / "native.yaml").write_text( + controller.native_configuration(workspace) + ) + (workspace / "cn-2.toml").write_text( + '[cn.sirius]\nenabled=true\nbackend="embedded"\ninput-mode="mo"\n' + 'max-waiting-queries=16\ncleanup-timeout="30s"\ngpu-streams = 2\n' + + "native-config-path=" + + json.dumps(str(workspace / "native.yaml")) + + "\n" + ) + arguments = argparse.Namespace( + workspace=workspace, + output=root / "output", + service=service_file, + runner=runner_file, + resource=True, + resource_phases=[1], + port_base=4000, + ) + + def cleanup(child): + if child.poll() is None: + os.killpg(child.pid, signal.SIGKILL) + child.wait(timeout=2) + child.stdin.close() + child.stdout.close() + + def writer(path, mode): + child = subprocess.Popen( + [sys.executable, "-c", script, str(path), str(size), mode], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + stack.callback(cleanup, child) + self.assertTrue(select.select([child.stdout], [], [], 2)[0]) + self.assertEqual(b"ready\n", child.stdout.readline()) + if mode == "export": + child.stdin.write(b"x") + child.stdin.flush() + self.assertTrue(select.select([child.stdout], [], [], 2)[0]) + self.assertEqual(b"written\n", child.stdout.readline()) + value = service_type.__new__(service_type) + value.process, value.file = child, io.BytesIO() + value.generation, value.targets = controller.pid_identity(child.pid), {} + children.append(child) + return value + + def factory(command, cwd, log, environment): + if command[:2] == ["nsys", "export"]: + return writer( + Path(command[command.index("--output") + 1]), "export" + ) + if command[:2] == ["nsys", "profile"] and stage == "finalize": + return writer(Path(log).parent / "growing.nsys-rep", "finalize") + value = MagicMock() + value.process.poll.return_value = None + value.process.pid = os.getpid() + if "worker" in command: + work = json.loads(Path(command[-1]).read_text()) + rows = copy.deepcopy(evidence()[3][:44]) + Path(work["records"]).write_text( + json.dumps( + { + "partial": True, + "schedule_sha256": hashlib.sha256( + v2.canonical(work["campaign"]["schedule"]) + ).hexdigest(), + "records": rows, + "excluded_warmups": [], + "cleanup_probes": [], + } + ) + ) + value.process.poll.return_value = 0 + value.process.returncode = 0 + return value + + def legacy_export(command, **kwargs): + legacy_calls.append(True) + Path(command[command.index("--output") + 1]).write_bytes(b"x" * size) + + def target(service, *_, **__): + service.track_target(service.process.pid) + return service.process.pid + + parse = stack.enter_context( + patch.object( + resource, + "allocation_summary", + side_effect=v2.CampaignError("parser reached"), + ) + ) + restore = stack.enter_context( + patch.object(v2.legacy, "_set_child_subreaper") + ) + for target, name, value in ( + (controller, "Service", factory), + ( + controller, + "controller_sha256", + lambda: identity["controller_sha256"], + ), + (controller, "wait_target", target), + (controller, "wait_ready", lambda *_, **__: None), + (controller, "PROFILE_WORKSPACE_BYTES", 512 << 10), + (controller, "PROFILE_FINALIZE_SECONDS", 2), + (resource, "MAX_LEDGER_BYTES", 0), + (resource, "MAX_SORT_FILE_BYTES", 0), + (v2.legacy, "_enter_exclusive_subreaper", lambda: False), + (v2.legacy, "_kill_and_reap_adopted_children", lambda *_: None), + ): + stack.enter_context(patch.object(target, name, value)) + stack.enter_context( + patch.object(controller.subprocess, "run", legacy_export) + ) + with self.assertRaises(v2.CampaignError) as caught: + controller.run_complete(arguments, provenance=identity) + if expect_quota: + error = caught.exception + messages = [] + while error is not None: + messages.append(str(error)) + error = error.__cause__ or error.__context__ + self.assertTrue( + any("quota exceeded" in value for value in messages), messages + ) + self.assertFalse( + legacy_calls, "blocking export returned before detecting quota" + ) + parse.assert_not_called() + restore.assert_not_called() + self.assertEqual(-signal.SIGKILL, children[-1].returncode) + else: + parse.assert_called_once() + self.assertEqual(0, children[-1].returncode) + restore.assert_called_once_with(False) + self.assertTrue(all(child.poll() is not None for child in children)) + self.assertFalse((arguments.output / "campaign.json").exists()) + self.assertFalse((arguments.output / "runs.jsonl").exists()) + + def test_resource_diagnostics_preserve_full_schedule_and_cannot_accept(self): + from sirius_acceptance_test import evidence + + data = evidence() + manifest = data[2] + rows = data[3][176:] + original = copy.deepcopy(manifest) + report = controller.resource_diagnostic_summary(manifest, rows, [6, 5]) + self.assertEqual([5, 6], report["resource_phases"]) + self.assertEqual(88, report["validated"]) + self.assertTrue(report["partial"]) + self.assertFalse(report["complete"]) + self.assertEqual(original, manifest) + with self.assertRaises(v2.CampaignError): + v2.summarize(manifest, rows) + for mutation in ( + lambda values: values.pop(), + lambda values: values[0].update(sequence=1), + lambda values: values[1].update(query_id=values[0]["query_id"]), + ): + changed = copy.deepcopy(rows) + mutation(changed) + with self.assertRaises(v2.CampaignError): + controller.resource_diagnostic_summary(manifest, changed, [5, 6]) + report = controller.resource_diagnostic_summary( + manifest, data[3], [1, 2, 3, 4, 5, 6] + ) + self.assertFalse(report["complete"]) + + def test_resource_phase_selection_rejects_invalid_or_timing_requests(self): + self.assertEqual((), controller.resource_phase_selection(False, None)) + self.assertEqual((5, 6), controller.resource_phase_selection(True, [6, 5])) + for resource, phases in ( + (False, [1]), + (True, []), + (True, [1, 1]), + (True, [0]), + (True, [7]), + (True, [True]), + ): + with self.assertRaises(v2.CampaignError): + controller.resource_phase_selection(resource, phases) + + def test_profile_workspace_size_rejects_an_oversized_file(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "trace.sqlite").write_bytes(b"12345") + self.assertEqual(5, controller.profile_workspace_bytes(root)) + with patch.object(controller, "PROFILE_WORKSPACE_BYTES", 4): + with self.assertRaisesRegex(v2.CampaignError, "quota"): + controller.profile_workspace_bytes(root) + + def test_workspace_scan_tolerates_retired_files_and_keeps_other_failures_fatal( + self, + ): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + retired = root / "compare-retired.ndjson" + retained = root / "retained" + retired.write_bytes(b"old") + retained.write_bytes(b"12345") + original = Path.stat + + def retire(path, *args, **kwargs): + if path == retired: + retired.unlink(missing_ok=True) + raise FileNotFoundError() + return original(path, *args, **kwargs) + + with patch.object(Path, "stat", autospec=True, side_effect=retire): + self.assertEqual(5, controller.profile_workspace_bytes(root)) + with patch.object(controller, "PROFILE_WORKSPACE_BYTES", 4): + with self.assertRaisesRegex(v2.CampaignError, "quota"): + controller.profile_workspace_bytes(root) + with patch.object(Path, "stat", side_effect=PermissionError()): + with self.assertRaises(PermissionError): + controller.profile_workspace_bytes(root) + + def test_profile_workspace_quota_counts_all_retained_phases(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for number in (5, 6): + phase = root / f"phase-{number}" + phase.mkdir() + (phase / "trace.sqlite").write_bytes(b"123") + with patch.object(controller, "PROFILE_WORKSPACE_BYTES", 4): + self.assertEqual( + 3, controller.profile_workspace_bytes(root / "phase-5") + ) + with self.assertRaisesRegex(v2.CampaignError, "quota"): + controller.profile_workspace_bytes(root) + + def test_controller_joins_orphans_after_a_worker_exits(self): + # The escaped child survives its worker's exit and becomes the outer + # controller's child. v2's test caller bounds and owns this fixture too. + script = """import json,subprocess,sys +import sirius_campaign as c +previous=c.v2.legacy._enter_exclusive_subreaper() +worker=subprocess.Popen([sys.executable,'-c', + 'import os,time; pid=os.fork(); ' + 'os.setsid() if pid==0 else None; ' + 'print(pid,flush=True) if pid else None; ' + 'time.sleep(30) if pid==0 else None'],stdout=subprocess.PIPE,text=True) +escaped=int(worker.stdout.readline()) +worker.wait(timeout=5) +assert escaped in c.v2.legacy._direct_child_pids() +c.close_services([],previous) +worker.stdout.close() +assert not c.v2.legacy._direct_child_pids() +print(json.dumps({'joined':True})) +""" + result = v2.BoundedExecutor([sys.executable, "-c", script], 10)({}) + self.assertEqual({"joined": True}, result) + + def test_cleanup_failure_still_joins_descendants_and_keeps_owner_poisoned(self): + service = MagicMock() + service.close.side_effect = v2.CampaignError("injected close failure") + with ( + patch.object(v2.legacy, "_kill_and_reap_adopted_children") as join, + patch.object(v2.legacy, "_set_child_subreaper") as restore, + ): + with self.assertRaisesRegex(v2.CampaignError, "cleanup failed"): + controller.close_services([service], False) + join.assert_called_once() + restore.assert_not_called() + + def test_profile_identity_is_written_before_exec_and_checked_by_generation(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "identity.json" + arguments = argparse.Namespace( + identity=path, + service=Path("/owned/service"), + configuration=Path("/owned/config"), + ) + with ( + patch.object(controller, "pid_identity", return_value=7), + patch.object(controller.os, "execve") as execute, + ): + controller.launch_target(arguments) + identity = json.loads(path.read_text()) + self.assertEqual(7, identity["generation"]) + self.assertEqual( + ["/owned/service", "-cfg", "/owned/config"], execute.call_args.args[1] + ) + service = MagicMock(targets={}) + with patch.object(controller, "pid_identity", return_value=7): + self.assertEqual(identity["pid"], controller.wait_target(service, path)) + self.assertEqual({identity["pid"]: 7}, service.targets) + service.targets.clear() + with patch.object(controller, "pid_identity", return_value=8): + with self.assertRaisesRegex(v2.CampaignError, "generation"): + controller.wait_target(service, path) + self.assertEqual({}, service.targets) + path.chmod(0o644) + with self.assertRaisesRegex(v2.CampaignError, "private"): + controller.wait_target(service, path) + + def test_pool_tags_require_both_phases_and_exact_native_uuid(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "sirius.log" + identity = "a" * 32 + lines = [] + for phase in ("QueryBegin", "QueryEnd"): + for tier, space, tail in ( + ("host", -1, "free_blocks=4"), + ("gpu", 0, "reserved=0 bytes"), + ): + lines.append( + f"[info] [{tier}_pool] {tier.upper()}:{space} {phase} instance=0xabc connection=1 window=3 query=8 allocated=0 bytes peak=9 bytes {tail}\n" + ) + lines.insert(2, f"[info] query 3 telemetry_query={identity}\n") + path.write_text("".join(lines)) + collector = controller.PoolCollector(root) + result = collector.collect({}, identity) + self.assertEqual(2, len(result["pools"])) + self.assertNotIn("instance", json.dumps(result)) + # Quent UUIDs are independent of MO UUIDs; exclusive CN ordering + # supplies the separate mapping, with complete window accounting. + self.assertEqual(result, collector.collect({}, "b" * 32, window_id=3)) + with self.assertRaises(v2.CampaignError): + collector.collect({}, "b" * 32) + path.write_text("".join(lines[:-1])) + with self.assertRaisesRegex(v2.CampaignError, "incomplete"): + collector.collect({}, identity) + + def test_configuration_is_private_at_creation(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "configuration.json" + controller.private_json(path, {"safe": "data"}) + self.assertEqual(0o600, path.stat().st_mode & 0o777) + with self.assertRaises(FileExistsError): + controller.private_json(path, {}) + + def test_window_accounting_is_bounded_and_rejects_extra_admissions(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "sirius.log" + collector = controller.PoolCollector(directory) + path.write_text(f"query 1 telemetry_query={'a'*32}\n") + # Match the engine's surrounding log whitespace. + path.write_text("[info] " + path.read_text()) + collector.validate_windows(1) + with path.open("a") as file: + file.write(f"[info] query 2 telemetry_query={'b'*32}\n") + with self.assertRaisesRegex(v2.CampaignError, "ambiguous"): + collector.validate_windows(1) + path.write_bytes(b"x" * ((1 << 20) + 1)) + with self.assertRaisesRegex(v2.CampaignError, "line exceeds"): + collector.validate_windows(0) + + def test_cleanup_observation_preserves_end_and_uses_next_idle_begin(self): + with tempfile.TemporaryDirectory() as directory: + lines = [] + for window in (1, 2): + for phase in ("QueryBegin", "QueryEnd"): + for tier, space, tail in ( + ("host", -1, "free_blocks=4"), + ("gpu", 0, "reserved=0 bytes"), + ): + amount = ( + 768 + if (window, phase, tier) == (1, "QueryEnd", "gpu") + else 0 + ) + lines.append( + f"[info] [{tier}_pool] {tier.upper()}:{space} {phase} instance=0xabc connection=1 window={window} query={window} allocated={amount} bytes peak=9 bytes {tail}\n" + ) + if phase == "QueryBegin": + lines.append( + f"[info] query {window} telemetry_query={'c'*32}\n" + ) + (Path(directory) / "sirius.log").write_text("".join(lines)) + executor = controller.PublicExecutor.__new__(controller.PublicExecutor) + executor.pools = controller.PoolCollector(directory) + executor.native_ordinal = 2 + executor.pool_offsets = {"a" * 32: ({}, 1), "b" * 32: ({}, 2)} + rows = [ + {"backend": "embedded", "query_id": value * 32} for value in ("a", "b") + ] + executor.attach_pools(rows) + gpu = next( + pool + for pool in rows[0]["native_pools"]["pools"] + if pool["tier"] == "gpu" + ) + self.assertEqual(768, gpu["end"]["allocated_bytes"]) + self.assertEqual(0, gpu["after_close"]["allocated_bytes"]) + self.assertEqual("b" * 32, gpu["after_close"]["query_id"]) + self.assertEqual({}, executor.pool_offsets) + + def test_profile_close_never_signals_reused_target_pid(self): + service = controller.Service.__new__(controller.Service) + service.targets = {123: 1} + service.process = MagicMock(pid=456) + service.process.poll.return_value = None + service.process.returncode = 0 + service.generation = 3 + service.file = MagicMock() + with ( + patch.object( + controller, + "pid_identity", + side_effect=lambda pid: 2 if pid == 123 else 3, + ), + patch.object(controller.os, "kill") as kill, + patch.object(controller.os, "killpg") as killpg, + ): + service.close() + kill.assert_not_called() + killpg.assert_not_called() + service.process.wait.assert_called_once_with( + timeout=controller.PROFILE_FINALIZE_SECONDS + ) + service.file.close.assert_called_once() + + def test_cuda_diagnostics_require_the_owned_pid_or_global_scope(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "trace.sqlite" + db = sqlite3.connect(path) + db.executescript( + """create table ENUM_CUDA_MEM_KIND(id integer,label text); + insert into ENUM_CUDA_MEM_KIND values(1,'Pinned'),(2,'Device'); + create table ENUM_CUDA_DEV_MEM_EVENT_OPER(id integer,label text); + insert into ENUM_CUDA_DEV_MEM_EVENT_OPER values(0,'Allocation'); + create table CUDA_GPU_MEMORY_USAGE_EVENTS(start integer,globalPid integer,contextId integer,address integer,bytes integer,memKind integer,memoryOperationType integer); + insert into CUDA_GPU_MEMORY_USAGE_EVENTS values(1,2063597568,0,10,64,1,0),(2,2063597568,0,20,128,2,0); + create table DIAGNOSTIC_EVENT(severity integer,text text,globalPid integer); + insert into DIAGNOSTIC_EVENT values(2,'No CUDA events collected',16777216000);""" + ) + db.commit() + resource.allocation_summary(path, 123) + for owner in (123 << 24, None, -1, 0): + db.execute("update DIAGNOSTIC_EVENT set globalPid=?", (owner,)) + db.commit() + with self.assertRaisesRegex(v2.CampaignError, "incomplete"): + resource.allocation_summary(path, 123) + db.close() + + def test_structured_events_correlate_exact_uuid_and_account(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "cn.log" + identity = "a" * 32 + schema = { + "msg": "MO campaign result schema", + "query_id": identity, + "account_id": 0, + } + terminal = { + "msg": "Sirius embedded execution", + "query_id": identity, + "account_id": 0, + } + path.write_text(json.dumps(schema) + "\n" + json.dumps(terminal) + "\n") + self.assertEqual( + (schema, terminal), controller.EventCollector(path).collect(0, identity) + ) + path.write_text(json.dumps(schema) + "\n" + json.dumps(schema) + "\n") + with self.assertRaisesRegex(v2.CampaignError, "duplicate"): + controller.EventCollector(path).collect(0, identity) + schema["account_id"] = 1 + path.write_text(json.dumps(schema) + "\n") + with self.assertRaisesRegex(v2.CampaignError, "account"): + controller.EventCollector(path).collect(0, identity) + + def test_metadata_rejection_still_releases_unadmitted_spool(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + root.chmod(0o700) + candidate = v2.build_campaign(provenance()) + candidate["schedule"] = candidate["schedule"][:1] + fake = output(root, "run-00000001-mo-native.ndjson", [1]) + fake.update( + { + "schema_version": 2, + "sequence": 1, + "route": candidate["schedule"][0]["route"], + "query_id": "a" * 32, + "wall_seconds": 1.0, + "first_row_seconds": 0.1, + } + ) + fake["output_schema_sha256"] = "f" * 64 + with self.assertRaises(v2.CampaignError): + v2.execute_campaign(candidate, lambda _: fake, v2.Scratch(root)) + self.assertEqual([], list(root.iterdir())) + + def test_partial_schedule_cannot_publish_complete_summary(self): + candidate = v2.build_campaign(provenance()) + candidate["schedule"] = candidate["schedule"][:2] + with self.assertRaisesRegex(v2.CampaignError, "partial"): + v2.summarize(candidate, []) + + def test_sampler_streams_large_endpoint_without_materializing(self): + sampler = controller.Sampler.__new__(controller.Sampler) + sampler.pid = 1 + sampler.generation = 2 + sampler.metric_url = "owned" + sampler.rss_peak = 0 + sampler.go_peak = 0 + sampler.samples = 0 + sampler.gpu = False + stream = io.BytesIO( + b"# other metric\n" * 100000 + b"go_memstats_heap_alloc_bytes 42\n" + ) + with ( + patch.object(controller, "pid_identity", return_value=2), + patch.object(controller, "process_observation", return_value=(0, 3)), + patch.object(controller.urllib.request, "urlopen", return_value=stream), + ): + sampler._sample() + self.assertEqual(42, sampler.go_peak) + self.assertEqual(1, sampler.samples) + + def test_allocation_trace_requires_both_sources_and_matching_frees(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "trace.sqlite" + db = sqlite3.connect(path) + db.executescript( + """create table ENUM_CUDA_MEM_KIND(id integer,label text); + insert into ENUM_CUDA_MEM_KIND values(2,'PINNED'),(3,'DEVICE'); + create table ENUM_CUDA_DEV_MEM_EVENT_OPER(id integer,label text); + insert into ENUM_CUDA_DEV_MEM_EVENT_OPER values(1,'ALLOC'),(2,'FREE'); + create table CUDA_GPU_MEMORY_USAGE_EVENTS(start integer,globalPid integer,contextId integer,address integer,bytes integer,memKind integer,memoryOperationType integer);""" + ) + pid = 123 + db.executemany( + "insert into CUDA_GPU_MEMORY_USAGE_EVENTS values(?,?,?,?,?,?,?)", + [ + (1, pid << 24, 0, 10, 64, 2, 1), + (2, pid << 24, 1, 20, 128, 3, 1), + (3, pid << 24, 0, 10, 64, 2, 2), + (4, pid << 24, 1, 20, 128, 3, 2), + ], + ) + db.commit() + result = resource.allocation_summary(path, pid) + self.assertEqual({"pinned": 64, "device": 128}, result["peak_bytes"]) + self.assertEqual({"pinned": 0, "device": 0}, result["final_bytes"]) + with self.assertRaises(v2.CampaignError): + resource.allocation_summary(path, 999) + db.close() + + def test_resource_tracker_spills_at_live_memory_bound(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "trace.sqlite" + db = sqlite3.connect(path) + db.executescript( + """create table ENUM_CUDA_MEM_KIND(id integer,label text); + insert into ENUM_CUDA_MEM_KIND values(1,'Pinned'),(2,'Device'); + create table ENUM_CUDA_DEV_MEM_EVENT_OPER(id integer,label text); + insert into ENUM_CUDA_DEV_MEM_EVENT_OPER values(0,'Allocation'); + create table CUDA_GPU_MEMORY_USAGE_EVENTS(start integer,globalPid integer,contextId integer,address integer,bytes integer,memKind integer,memoryOperationType integer); + insert into CUDA_GPU_MEMORY_USAGE_EVENTS values(1,2063597568,0,10,64,1,0),(2,2063597568,0,20,128,2,0);""" + ) + db.commit() + db.close() + with patch.object(resource, "MAX_LIVE_ALLOCATIONS", 1): + result = resource.allocation_summary(path, 123) + self.assertEqual({"pinned": 64, "device": 128}, result["peak_bytes"]) + self.assertEqual([], list(Path(directory).glob("sirius-ledger-*"))) + + def test_spilled_allocation_generations_and_quota_keep_cleanup_owner(self): + with tempfile.TemporaryDirectory() as directory: + ledger = resource.AllocationLedger(Path(directory)) + try: + with patch.object(resource, "MAX_LIVE_ALLOCATIONS", 1): + ledger.add((None, 10, "pinned"), 64) + ledger.add((1, 20, "device"), 128) + self.assertIsNotNone(ledger.database) + self.assertEqual({}, ledger.memory) + with self.assertRaisesRegex(v2.CampaignError, "reused"): + ledger.add((1, 20, "device"), 1) + self.assertEqual(128, ledger.release((1, 20, "device"))) + with self.assertRaisesRegex(v2.CampaignError, "matching acquisition"): + ledger.release((1, 20, "device")) + ledger.operations = 4095 + with patch.object(resource, "MAX_LEDGER_BYTES", 1): + with self.assertRaisesRegex(v2.CampaignError, "quota"): + ledger.add((1, 30, "device"), 1) + finally: + ledger.close() + self.assertEqual([], list(Path(directory).glob("sirius-ledger-*"))) + + def test_spilled_ledger_cannot_grow_past_sqlite_page_quota(self): + with tempfile.TemporaryDirectory() as directory: + ledger = resource.AllocationLedger(Path(directory)) + try: + with ( + patch.object(resource, "MAX_LIVE_ALLOCATIONS", 1), + patch.object(resource, "MAX_LEDGER_BYTES", 8192), + ): + with self.assertRaisesRegex(v2.CampaignError, "quota"): + for address in range(10000): + ledger.add((1, address, "device"), 64) + self.assertLessEqual( + (Path(ledger.directory.name) / "live.sqlite").stat().st_size, + 8192, + ) + self.assertLess(ledger.operations, 4096) + finally: + ledger.close() + self.assertEqual([], list(Path(directory).glob("sirius-ledger-*"))) + + def test_sqlite_sort_file_limit_cleans_scope_and_restores_os_limits(self): + limits = { + kind: resource.posix_resource.getrlimit(kind) + for kind in ( + resource.posix_resource.RLIMIT_FSIZE, + resource.posix_resource.RLIMIT_AS, + ) + } + old_directory = resource.os.environ.get("SQLITE_TMPDIR") + old_signal = resource.signal.getsignal(resource.signal.SIGXFSZ) + with tempfile.TemporaryDirectory() as directory: + with patch.object(resource, "MAX_SORT_FILE_BYTES", 8192): + with resource.bounded_sqlite_sort(Path(directory)) as scratch: + path = scratch / "sort-pma" + with self.assertRaises(OSError): + path.write_bytes(b"x" * 8193) + self.assertLessEqual(path.stat().st_size, 8192) + self.assertEqual([], list(Path(directory).iterdir())) + self.assertEqual(old_directory, resource.os.environ.get("SQLITE_TMPDIR")) + self.assertEqual(old_signal, resource.signal.getsignal(resource.signal.SIGXFSZ)) + for kind, old in limits.items(): + self.assertEqual(old, resource.posix_resource.getrlimit(kind)) + + def test_nvml_zero_before_context_is_an_observation(self): + nvml = controller.NVML.__new__(controller.NVML) + nvml.handle = controller.ctypes.c_void_p(1) + nvml.library = MagicMock() + nvml.library.nvmlDeviceGetComputeRunningProcesses.return_value = 0 + self.assertEqual(0, nvml.used(123)) + + +if __name__ == "__main__": + unittest.main() diff --git a/optools/sirius_campaign_v2.py b/optools/sirius_campaign_v2.py new file mode 100644 index 0000000000000..8ed040709bcac --- /dev/null +++ b/optools/sirius_campaign_v2.py @@ -0,0 +1,1237 @@ +#!/usr/bin/env python3 +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bounded public-MO/Sirius campaign protocol v2; v1 remains independently valid.""" +from __future__ import annotations + +import contextlib +import copy +from decimal import Decimal, InvalidOperation +import hashlib +import heapq +import io +import json +import math +import os +from pathlib import Path +import selectors +import signal +import stat +import statistics +import struct +import subprocess +import sys +import tempfile +import time + +import sirius_parity as legacy + +MEMORY_BYTES = 32 << 20 +CHUNK_BYTES = 8 << 20 +SCRATCH_BYTES = 4 << 30 +ROW_BYTES = 1 << 20 +OUTPUT_BYTES = 1 << 20 +STDERR_BYTES = 64 << 10 +ROUTES = ( + {"id": "mo-native", "backend": "native", "scan_mode": "mo"}, + {"id": "embedded-mo", "backend": "embedded", "scan_mode": "mo"}, +) +# Zero-based output positions of the unchanged canonical SQL ORDER BY keys. +# Direction is irrelevant for identifying peers; the ordered stream is retained. +ORDER_KEYS = { + 1: (0, 1), + 2: (0, 2, 1, 3), + 3: (1, 2), + 4: (0,), + 5: (1,), + 7: (0, 1, 2), + 8: (0,), + 9: (0, 1), + 10: (2,), + 11: (1,), + 12: (0,), + 13: (1, 0), + 15: (0,), + 16: (3, 0, 1, 2), + 18: (4, 3), + 20: (0,), + 21: (1, 0), + 22: (0,), +} + +CampaignError = legacy.CampaignError + + +def canonical(value): + return legacy.canonical_json(value).encode("utf-8") + + +def query_uuid(value): + if not isinstance(value, str): + raise CampaignError("missing query identity") + value = value.replace("-", "").lower() + if ( + len(value) != 32 + or value == "0" * 32 + or any(char not in "0123456789abcdef" for char in value) + ): + raise CampaignError("invalid query identity") + return value + + +def build_campaign(provenance, *, resource=False, flight_profiles=()): + required = ( + "matrixone_revision", + "sirius_revision", + "sdk_sha256", + "query_sha256", + "datasets", + "hardware", + "config", + "toolchain", + "service_binary_sha256", + "runner_binary_sha256", + "sdk_build_mode", + "controller_sha256", + ) + if not isinstance(provenance, dict) or set(provenance) != set(required): + raise CampaignError("incomplete v2 provenance") + for field in ("matrixone_revision", "sirius_revision"): + value = provenance[field] + if ( + not isinstance(value, str) + or len(value) != 40 + or any(x not in "0123456789abcdef" for x in value) + ): + raise CampaignError("invalid v2 revision") + if any( + not legacy._is_sha256(provenance[key]) + for key in ( + "sdk_sha256", + "service_binary_sha256", + "runner_binary_sha256", + "controller_sha256", + ) + ): + raise CampaignError("invalid SDK identity") + if provenance["sdk_build_mode"] not in ("release", "development"): + raise CampaignError("invalid SDK build mode") + for field, allowed in ( + ( + "hardware", + { + "gpu_name", + "gpu_uuid", + "gpu_total_bytes", + "cpu_model", + "cpu_count", + "host_total_bytes", + }, + ), + ( + "toolchain", + {"go", "cxx", "cuda", "driver", "pixi_lock_sha256", "nsight", "gdb"}, + ), + ( + "config", + { + "gpu_memory_bytes", + "native_host_memory_bytes", + "input_window_bytes", + "result_window_bytes", + "active_queries", + "waiting_queries", + "cleanup_timeout_seconds", + "query_timeout_seconds", + "resource_timeout_seconds", + "session", + }, + ), + ): + value = provenance[field] + if not isinstance(value, dict) or set(value) != allowed: + raise CampaignError("invalid bounded provenance fields") + if len(canonical(value)) > 4096: + raise CampaignError("provenance section exceeds bound") + if not legacy._is_sha256(provenance["toolchain"]["pixi_lock_sha256"]): + raise CampaignError("invalid frozen toolchain lock identity") + if provenance["config"]["session"] != { + "campaign_evidence": True, + "div_precision_increment": 4, + "time_zone": "+00:00", + }: + raise CampaignError("campaign session semantics differ") + expected = { + "gpu_memory_bytes": 6 << 30, + "native_host_memory_bytes": 16 << 30, + "input_window_bytes": 64 << 20, + "result_window_bytes": 64 << 20, + "active_queries": 1, + "waiting_queries": 16, + "cleanup_timeout_seconds": 30, + "query_timeout_seconds": 3600, + "resource_timeout_seconds": 10800, + } + if any( + type(provenance["config"][key]) is not int or provenance["config"][key] != value + for key, value in expected.items() + ): + raise CampaignError("campaign limits differ from the approved profile") + for key, value in provenance["hardware"].items(): + if key in ("gpu_total_bytes", "cpu_count", "host_total_bytes"): + if type(value) is not int or not 0 < value < 1 << 60: + raise CampaignError("invalid hardware capacity") + elif ( + not isinstance(value, str) + or not 0 < len(value) <= 256 + or any(ord(c) < 32 for c in value) + ): + raise CampaignError("invalid hardware identity") + if any( + not isinstance(value, str) + or not 0 < len(value) <= 512 + or any(ord(c) < 32 for c in value) + for value in provenance["toolchain"].values() + ): + raise CampaignError("invalid toolchain identity") + hashes = provenance["query_sha256"] + if set(hashes) != {f"Q{x}" for x in range(1, 23)} or not all( + legacy._is_sha256(x) for x in hashes.values() + ): + raise CampaignError("invalid canonical query inventory") + datasets = provenance["datasets"] + if set(datasets) != {"SF1", "SF10"} or not all( + legacy._is_sha256(x) for x in datasets.values() + ): + raise CampaignError("invalid immutable dataset identity") + profiles = copy.deepcopy(list(flight_profiles)) + if len(profiles) > 2: + raise CampaignError("Flight profile count exceeds bound") + identities = set() + for profile in profiles: + if ( + not isinstance(profile, dict) + or not set(profile) <= {"id", "coverage", "status", "reason", "provenance"} + or profile.get("id") not in ("flight-mo", "flight-tae") + or profile["id"] in identities + or not isinstance(profile.get("coverage"), list) + or any( + type(q) is not int or q not in range(1, 23) for q in profile["coverage"] + ) + ): + raise CampaignError("invalid declared Flight coverage") + identities.add(profile["id"]) + if len(set(profile["coverage"])) != len(profile["coverage"]): + raise CampaignError("duplicate Flight coverage") + if not profile["coverage"]: + if ( + profile.get("status", "N/A") != "N/A" + or profile.get("reason", "not-provisioned") + not in {"not-provisioned", "unavailable", "unsupported"} + or "provenance" in profile + ): + raise CampaignError("invalid preflight Flight availability") + continue + proof = profile.get("provenance") + required = { + "matrixone_revision", + "sirius_revision", + "launcher_sha256", + "configuration_sha256", + "server_binary_sha256", + "tn_no_gc_verified", + } + if not isinstance(proof, dict) or set(proof) != required: + raise CampaignError("missing declared Flight provenance") + if ( + any( + not isinstance(proof[key], str) + or len(proof[key]) != 40 + or any(char not in "0123456789abcdef" for char in proof[key]) + for key in ("matrixone_revision", "sirius_revision") + ) + or any( + not legacy._is_sha256(proof[key]) + for key in ( + "launcher_sha256", + "configuration_sha256", + "server_binary_sha256", + ) + ) + or type(proof["tn_no_gc_verified"]) is not bool + or profile["id"] == "flight-tae" + and not proof["tn_no_gc_verified"] + ): + raise CampaignError("invalid declared Flight provenance") + schedule = [] + + def append(scale, phase, stream, suite, repetition, query, route): + spec = legacy._run_spec( + len(schedule) + 1, + phase, + route, + query, + stream, + suite, + repetition, + phase == "performance" and suite > 0, + scale, + datasets[f"SF{scale}"], + hashes[f"Q{query}"], + ) + spec["schema_version"] = 2 + spec["order_keys"] = list(ORDER_KEYS.get(query, ())) + schedule.append(spec) + + def pair(scale, phase, stream, suite, repetition, query): + offset = (scale + query + stream + suite + repetition) % 2 + for route in ROUTES[offset:] + ROUTES[:offset]: + append(scale, phase, stream, suite, repetition, query, route) + for profile in profiles: + if query in profile["coverage"]: + append( + scale, + phase, + stream, + suite, + repetition, + query, + { + "id": profile["id"], + "backend": "flight", + "scan_mode": profile["id"].split("-")[1], + }, + ) + + for scale in (1, 10): + if resource: + for stream in (1, 4, 2): + for query in range(1, 23): + pair(scale, "resource-validation", stream, 0, 1, query) + else: + for stream in (1, 4): + for query in range(1, 23): + pair(scale, "stream-validation", stream, 0, 1, query) + for suite in range(6): + for query in range(1, 23): + pair(scale, "performance", 2, suite, 1, query) + for repetition in range(1, 11): + pair(scale, "q9-concurrency", 2, 0, repetition, 9) + return { + "schema_version": 2, + "resource_campaign": resource, + "provenance": copy.deepcopy(provenance), + "routes": list(ROUTES), + "flight_profiles": profiles, + "schedule": schedule, + "gpu_concurrency": 1, + "fallback_allowed": False, + "memory_bytes": MEMORY_BYTES, + "scratch_bytes": SCRATCH_BYTES, + } + + +class BoundedExecutor(legacy.SubprocessExecutor): + """Reuse v1's exclusive ownership; bound both pipes before JSON decoding.""" + + def _call_exclusive(self, spec): + if self.poisoned: + raise CampaignError("runner cleanup owner is poisoned") + previous = legacy._enter_exclusive_subreaper() + process = None + try: + environment = os.environ.copy() + environment["MO_SIRIUS_CAMPAIGN_RUN"] = canonical(spec).decode() + process = subprocess.Popen( + self.command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + start_new_session=True, + ) + buffers = {"stdout": bytearray(), "stderr": bytearray()} + deadline = time.monotonic() + self.timeout_seconds + with selectors.DefaultSelector() as selector: + for name in buffers: + pipe = getattr(process, name) + os.set_blocking(pipe.fileno(), False) + selector.register(pipe, selectors.EVENT_READ, name) + while selector.get_map(): + if time.monotonic() >= deadline: + raise CampaignError("runner deadline exceeded") + for key, _ in selector.select( + min(0.1, max(0, deadline - time.monotonic())) + ): + chunk = os.read(key.fileobj.fileno(), 64 << 10) + if not chunk: + selector.unregister(key.fileobj) + continue + buffer = buffers[key.data] + limit = OUTPUT_BYTES if key.data == "stdout" else STDERR_BYTES + if len(chunk) > limit - len(buffer): + raise CampaignError("runner pipe bound exceeded") + buffer.extend(chunk) + if process.poll() is not None: + deadline = min( + deadline, + time.monotonic() + legacy.RUNNER_CLEANUP_TIMEOUT_SECONDS, + ) + process.wait(timeout=max(0.001, deadline - time.monotonic())) + status = process.returncode + legacy._cleanup_owned_processes(process, close_pipes=True) + process = None + if status: + raise CampaignError("runner exited unsuccessfully") + output = json.loads(buffers["stdout"]) + if not isinstance(output, dict): + raise CampaignError("runner descriptor is not an object") + return output + finally: + if process is not None: + try: + legacy._cleanup_owned_processes(process, close_pipes=True) + except BaseException: + self.poisoned = True + raise + if legacy._direct_child_pids(): + self.poisoned = True + raise CampaignError("runner descendants did not quiesce") + legacy._set_child_subreaper(previous) + + +class Scratch: + """Exactly one owner for admitted spools and comparator runs.""" + + def __init__(self, root, limit=SCRATCH_BYTES): + self.root = Path(root) + info = self.root.lstat() + if not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o700: + raise CampaignError("scratch root is not private") + self.limit = limit + self.owned = {} + self.used = 0 + + def admit(self, result): + name = result.get("spool") + if ( + not isinstance(name, str) + or Path(name).name != name + or name in ("", ".", "..") + or "\0" in name + or name in self.owned + ): + raise CampaignError("invalid or duplicated spool ownership") + size = result.get("bytes") + if type(size) is not int or size < 0 or size > self.limit - self.used: + raise CampaignError("scratch quota exceeded") + descriptor = os.open( + self.root / name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC + ) + try: + info = os.fstat(descriptor) + if ( + not stat.S_ISREG(info.st_mode) + or stat.S_IMODE(info.st_mode) != 0o600 + or info.st_nlink != 1 + or info.st_size != size + ): + raise CampaignError("spool is not a private complete regular file") + if ( + not legacy._is_sha256(result.get("sha256")) + or type(result.get("row_count")) is not int + or result["row_count"] < 0 + ): + raise CampaignError("invalid spool digest or count") + self.owned[name] = size + self.used += size + return os.fdopen(descriptor, "rb", buffering=64 << 10) + except BaseException: + os.close(descriptor) + raise + + def create(self): + descriptor, name = tempfile.mkstemp( + prefix="compare-", suffix=".ndjson", dir=self.root + ) + name = Path(name).name + self.owned[name] = 0 + return name, os.fdopen(descriptor, "wb", buffering=64 << 10) + + def write(self, name, file, data): + if len(data) > self.limit - self.used: + raise CampaignError("comparator scratch quota exceeded") + file.write(data) + self.owned[name] += len(data) + self.used += len(data) + + def release(self, name): + size = self.owned.pop(name, None) + if size is not None: + try: + (self.root / name).unlink() + finally: + self.used -= size + + def close(self): + for name in tuple(self.owned): + self.release(name) + + +def validate_schema(output): + result = output.get("result", {}) + public = result.get("public_schema") + bound = output.get("output_schema") + if ( + not isinstance(public, list) + or not 0 < len(public) <= 1024 + or not isinstance(bound, list) + or len(public) != len(bound) + ): + raise CampaignError("incomplete public/bound schema") + names = 0 + digest = hashlib.sha256(b"sirius-mo-output-schema-v1\0") + for column, descriptor in zip(public, bound): + if not isinstance(column, dict) or not isinstance(descriptor, dict): + raise CampaignError("invalid schema descriptor") + name = column.get("name") + if ( + not isinstance(name, str) + or not isinstance(column.get("sql_type"), str) + or column.get("nullable_known") is not True + ): + raise CampaignError("public schema field unavailable") + raw = name.encode("utf-8") + names += len(raw) + if names > 1 << 20: + raise CampaignError("schema names exceed bound") + if ( + set(descriptor) != {"oid", "precision", "scale", "nullable"} + or any( + type(descriptor[k]) is not int for k in ("oid", "precision", "scale") + ) + or type(descriptor["nullable"]) is not bool + ): + raise CampaignError("invalid physical descriptor") + if descriptor["nullable"] != column.get("nullable"): + raise CampaignError("public/bound nullability drift") + digest.update( + struct.pack( + " column["precision"]: + raise CampaignError("noncanonical or overflowing coefficient") + elif kind == "float": + # Canonical decimal TPC-H has no approved approximate columns. + raise CampaignError("approximate column has no approved v2 tolerance") + elif kind == "binary": + import base64 + + try: + base64.b64decode(value, validate=True) + except ValueError as error: + raise CampaignError("invalid binary cell") from error + elif kind != sql_type.lower(): + raise CampaignError("string/date cell/schema drift") + result.append( + { + "type": kind, + "value": value, + **({"scale": entry.get("scale", 0)} if kind == "decimal" else {}), + } + ) + return result + + +def rows(file, descriptor, schema): + hasher = hashlib.sha256() + count = total = 0 + while True: + raw = file.readline(ROW_BYTES + 2) + if not raw: + break + if len(raw) > ROW_BYTES + 1 or not raw.endswith(b"\n"): + raise CampaignError("oversized or truncated spool row") + hasher.update(raw) + total += len(raw) + count += 1 + if count > descriptor["row_count"] or total > descriptor["bytes"]: + raise CampaignError("spool has trailing rows or bytes") + yield normal_row(raw, schema) + if ( + count != descriptor["row_count"] + or total != descriptor["bytes"] + or hasher.hexdigest() != descriptor["sha256"] + ): + raise CampaignError("spool count/byte/digest mismatch") + + +class ExternalBag: + """8 MiB accounted runs and fan-in eight leave room below 32 MiB.""" + + def __init__(self, scratch): + self.scratch = scratch + self.chunk = [] + self.size = 0 + self.runs = [] + + def add(self, row): + value = canonical(row) + b"\n" + amount = sys.getsizeof(value) + 16 + if self.chunk and self.size + amount > CHUNK_BYTES: + self.flush() + if amount > ROW_BYTES + 256: + raise CampaignError("normalized row exceeds bound") + self.chunk.append(value) + self.size += amount + + def flush(self): + if not self.chunk: + return + self.chunk.sort() + name, file = self.scratch.create() + try: + with file: + for value in self.chunk: + self.scratch.write(name, file, value) + self.runs.append(name) + except BaseException: + self.scratch.release(name) + raise + self.chunk = [] + self.size = 0 + + def merged(self): + self.flush() + while len(self.runs) > 8: + group = self.runs[:8] + self.runs = self.runs[8:] + name, destination = self.scratch.create() + try: + with contextlib.ExitStack() as stack: + inputs = [ + stack.enter_context( + (self.scratch.root / item).open("rb", buffering=64 << 10) + ) + for item in group + ] + with destination: + for value in heapq.merge(*inputs): + self.scratch.write(name, destination, value) + for item in group: + self.scratch.release(item) + self.runs.append(name) + except BaseException: + destination.close() + self.scratch.release(name) + raise + with contextlib.ExitStack() as stack: + inputs = [ + stack.enter_context( + (self.scratch.root / item).open("rb", buffering=64 << 10) + ) + for item in self.runs + ] + yield from heapq.merge(*inputs) + + def close(self): + self.chunk = [] + for name in self.runs: + self.scratch.release(name) + self.runs = [] + + +def compare_bags(left, right, scratch): + first, second = ExternalBag(scratch), ExternalBag(scratch) + try: + for row in left: + first.add(row) + for row in right: + second.add(row) + a, b = first.merged(), second.merged() + try: + while True: + x, y = next(a, None), next(b, None) + if x != y: + raise CampaignError("result value or duplicate-count mismatch") + if x is None: + break + finally: + a.close() + b.close() + finally: + first.close() + second.close() + + +def compare_results(expected, actual, keys, scratch): + schema = validate_schema(expected) + other = validate_schema(actual) + if ( + schema != other + or expected["output_schema"] != actual["output_schema"] + or expected["output_schema_sha256"] != actual["output_schema_sha256"] + ): + raise CampaignError("result schema mismatch") + if expected["result"]["row_count"] != actual["result"]["row_count"]: + raise CampaignError("result cardinality mismatch") + if any(type(key) is not int or not 0 <= key < len(schema) for key in keys): + raise CampaignError("invalid canonical ordering key") + names = [] + try: + with contextlib.ExitStack() as stack: + a = stack.enter_context(scratch.admit(expected["result"])) + names.append(expected["result"]["spool"]) + b = stack.enter_context(scratch.admit(actual["result"])) + names.append(actual["result"]["spool"]) + first = rows(a, expected["result"], schema) + second = rows(b, actual["result"], schema) + if not keys: + compare_bags(first, second, scratch) + else: + import itertools + + peer = lambda row: canonical([row[index] for index in keys]) + groups_a, groups_b = itertools.groupby(first, peer), itertools.groupby( + second, peer + ) + while True: + group_a, group_b = next(groups_a, None), next(groups_b, None) + if group_a is None or group_b is None: + if group_a != group_b: + raise CampaignError("ordered result length mismatch") + break + if group_a[0] != group_b[0]: + raise CampaignError("ordered peer or LIMIT membership mismatch") + compare_bags(group_a[1], group_b[1], scratch) + finally: + for name in names: + scratch.release(name) + + +def validate_run(spec, output): + if ( + output.get("schema_version") != 2 + or output.get("sequence") != spec["sequence"] + or output.get("route") != spec["route"] + ): + raise CampaignError("runner run identity mismatch") + query_uuid(output.get("query_id")) + wall, first = output.get("wall_seconds"), output.get("first_row_seconds") + if not isinstance(wall, (int, float)) or not math.isfinite(wall) or wall <= 0: + raise CampaignError("invalid dispatch/drain time") + count = output.get("result", {}).get("row_count") + if ( + (count == 0) != (first is None) + or first is not None + and ( + not isinstance(first, (int, float)) + or not math.isfinite(first) + or not 0 <= first <= wall + ) + ): + raise CampaignError("invalid actual first-row evidence") + validate_schema(output) + if spec["backend"] == "embedded": + event = output.get("terminal_event", {}) + if query_uuid(event.get("query_id")) != query_uuid(output["query_id"]): + raise CampaignError("terminal query identity mismatch") + if ( + event.get("backend") != "embedded" + or event.get("scan_mode") != "mo" + or event.get("fallback") is not False + or event.get("native_capabilities") != 31 + or event.get("numeric_profile") != "mo-exact-decimal-v1" + or event.get("gpu_streams") != spec["streams"] + or event.get("terminal_health") != "healthy" + ): + raise CampaignError("embedded execution evidence mismatch") + stats = event.get("execution_stats", {}) + if ( + stats.get("terminal") is not True + or stats.get("fatal") is not False + or type(stats.get("gpu_tasks_completed")) is not int + or stats["gpu_tasks_completed"] <= 0 + or stats.get("mo_input_retained_charged_bytes") != 0 + or stats.get("result_retained_charged_bytes") != 0 + ): + raise CampaignError("missing GPU work or unhealthy retained ownership") + if ( + event.get("verified_output_schema") != output["output_schema"] + or event.get("output_schema_sha256") != output["output_schema_sha256"] + ): + raise CampaignError("frontend/prepared schema drift") + + +def artifact_run(spec, output): + # Explicit whitelist: never retain headings, values, paths or unknown fields. + observations = {} + for name in ("host_process", "go_heap", "cpu", "gpu_residency"): + observation = output.get("observations", {}).get(name) + if observation is None: + continue + allowed = { + "source", + "scope", + "pid", + "generation", + "units", + "interval_seconds", + "samples", + "peak_bytes", + "baseline_bytes", + "final_bytes", + "value", + "status", + } + if not isinstance(observation, dict) or not set(observation) <= allowed: + raise CampaignError("invalid observation shape") + if ( + observation.get("source") + not in (None, "proc-stat", "go-prometheus", "nvml-process-v1") + or observation.get("scope") not in (None, "cn-process") + or observation.get("units") not in (None, "bytes", "seconds") + ): + raise CampaignError("invalid observation identity") + for key in ( + "pid", + "generation", + "samples", + "peak_bytes", + "baseline_bytes", + "final_bytes", + ): + if key in observation and ( + type(observation[key]) is not int or observation[key] < 0 + ): + raise CampaignError("invalid observation counter") + for key in ("value", "interval_seconds"): + if key in observation and ( + type(observation[key]) not in (int, float) + or not math.isfinite(observation[key]) + or observation[key] < 0 + ): + raise CampaignError("invalid observation duration") + if observation.get("status") not in (None, "N/A"): + raise CampaignError("invalid observation status") + observations[name] = observation + terminal = output.get("terminal_event") + execution = ( + { + key: terminal[key] + for key in ( + "backend", + "scan_mode", + "fallback", + "native_capabilities", + "numeric_profile", + "gpu_streams", + "terminal_health", + ) + } + if terminal + else None + ) + return { + **spec, + "query_id": query_uuid(output["query_id"]), + "wall_seconds": output["wall_seconds"], + "first_row_seconds": output["first_row_seconds"], + "row_count": output["result"]["row_count"], + "result_sha256": output["result"]["sha256"], + "output_schema": output["output_schema"], + "output_schema_sha256": output["output_schema_sha256"], + "observations": observations, + **( + {"resource_span": output["resource_span"]} + if "resource_span" in output + else {} + ), + **( + {"native_pools": output["native_pools"]} if "native_pools" in output else {} + ), + "execution": execution, + "execution_stats": (output.get("terminal_event") or {}).get("execution_stats"), + } + + +def validate_record(spec, record): + if not isinstance(record, dict) or any( + record.get(key) != value for key, value in spec.items() + ): + raise CampaignError("record schedule identity mismatch") + query_uuid(record.get("query_id")) + count, wall, first = ( + record.get("row_count"), + record.get("wall_seconds"), + record.get("first_row_seconds"), + ) + if ( + type(count) is not int + or count < 0 + or type(wall) not in (int, float) + or not math.isfinite(wall) + or wall <= 0 + ): + raise CampaignError("invalid retained result count or timing") + if ( + (count == 0) != (first is None) + or first is not None + and ( + type(first) not in (int, float) + or not math.isfinite(first) + or not 0 <= first <= wall + ) + ): + raise CampaignError("invalid retained first-row timing") + if not legacy._is_sha256(record.get("result_sha256")) or not legacy._is_sha256( + record.get("output_schema_sha256") + ): + raise CampaignError("invalid retained result/schema identity") + schema = record.get("output_schema") + if not isinstance(schema, list) or not 0 < len(schema) <= 1024: + raise CampaignError("invalid retained physical schema") + for descriptor in schema: + if ( + not isinstance(descriptor, dict) + or set(descriptor) != {"oid", "precision", "scale", "nullable"} + or any( + type(descriptor[k]) is not int for k in ("oid", "precision", "scale") + ) + or type(descriptor["nullable"]) is not bool + ): + raise CampaignError("invalid retained physical descriptor") + if spec["backend"] == "embedded": + expected = { + "backend": "embedded", + "scan_mode": "mo", + "fallback": False, + "native_capabilities": 31, + "numeric_profile": "mo-exact-decimal-v1", + "gpu_streams": spec["streams"], + "terminal_health": "healthy", + } + stats = record.get("execution_stats") + if ( + record.get("execution") != expected + or not isinstance(stats, dict) + or stats.get("terminal") is not True + or stats.get("fatal") is not False + or type(stats.get("gpu_tasks_completed")) is not int + or stats["gpu_tasks_completed"] <= 0 + or stats.get("mo_input_retained_charged_bytes") != 0 + or stats.get("result_retained_charged_bytes") != 0 + or stats.get("result_rows") != count + ): + raise CampaignError("retained terminal GPU proof is incomplete") + elif spec["backend"] == "native" and ( + record.get("execution") is not None or record.get("execution_stats") is not None + ): + raise CampaignError("native retained evidence entered GPU execution") + + +def execute_campaign(campaign, executor, scratch): + records, pending = [], {} + identities = set() + produced = set() + try: + for spec in campaign["schedule"]: + request = copy.deepcopy(spec) + request["spool_budget_bytes"] = min( + SCRATCH_BYTES // 2, scratch.limit - scratch.used + ) + output = executor(request) + name = ( + output.get("result", {}).get("spool") + if isinstance(output, dict) + else None + ) + if ( + isinstance(name, str) + and Path(name).name == name + and name not in ("", ".", "..") + ): + produced.add(name) + validate_run(spec, output) + identity = query_uuid(output["query_id"]) + if identity in identities: + raise CampaignError("duplicate query identity") + identities.add(identity) + key = legacy._oracle_cell(spec) + cell = pending.setdefault(key, {}) + if spec["route"] in cell: + raise CampaignError("duplicate oracle route") + cell[spec["route"]] = output + required_routes = [route["id"] for route in ROUTES] + optional = [ + profile["id"] + for profile in campaign["flight_profiles"] + if spec["query"] in profile["coverage"] + ] + if all(route in cell for route in required_routes + optional): + # The native oracle remains open only through its finite peer set. + if optional: + # Compare each optional result with the independently executed + # oracle before consuming the mandatory pair. Spool contents + # are fully compared, never accepted solely by a digest. + for route in optional: + oracle = copy.deepcopy(cell["mo-native"]) + source = scratch.root / oracle["result"]["spool"] + name, file = scratch.create() + try: + with source.open("rb") as reader, file: + for chunk in iter(lambda: reader.read(64 << 10), b""): + scratch.write(name, file, chunk) + # The duplicate is already owned as comparator scratch. + size = scratch.owned.pop(name) + scratch.used -= size + oracle["result"]["spool"] = name + compare_results( + oracle, cell[route], spec["order_keys"], scratch + ) + finally: + scratch.release(name) + (scratch.root / name).unlink(missing_ok=True) + compare_results( + cell["mo-native"], cell["embedded-mo"], spec["order_keys"], scratch + ) + del pending[key] + records.append(artifact_run(spec, output)) + if pending or len(records) != len(campaign["schedule"]): + raise CampaignError("incomplete required campaign") + return records + finally: + scratch.close() + for name in produced: + (scratch.root / name).unlink(missing_ok=True) + # Unadmitted outputs are still invocation-owned; callers create a fresh root. + for cell in pending.values(): + for output in cell.values(): + name = output.get("result", {}).get("spool") + if ( + isinstance(name, str) + and Path(name).name == name + and name not in ("", ".", "..") + ): + path = scratch.root / name + if path.is_file() and not path.is_symlink(): + path.unlink() + + +def summarize(campaign, records): + trusted = build_campaign( + campaign["provenance"], + resource=campaign["resource_campaign"], + flight_profiles=campaign["flight_profiles"], + ) + if campaign != trusted: + raise CampaignError("summary rejects a partial or modified acceptance schedule") + if len(records) != len(campaign["schedule"]): + raise CampaignError("summary requires complete ordered evidence") + identities, pending = set(), {} + for spec, record in zip(campaign["schedule"], records): + validate_record(spec, record) + identity = query_uuid(record["query_id"]) + if identity in identities: + raise CampaignError("summary contains duplicate query identity") + identities.add(identity) + key = legacy._oracle_cell(spec) + cell = pending.setdefault(key, {}) + if record["route"] in cell: + raise CampaignError("summary contains duplicate oracle route") + cell[record["route"]] = record + required = {route["id"] for route in ROUTES} | { + profile["id"] + for profile in campaign["flight_profiles"] + if spec["query"] in profile["coverage"] + } + if set(cell) == required: + oracle = cell["mo-native"] + for peer in cell.values(): + if peer["row_count"] != oracle["row_count"]: + raise CampaignError("retained oracle result cardinality mismatch") + if any( + peer[field] != oracle[field] + for field in ("output_schema", "output_schema_sha256") + ): + raise CampaignError("retained oracle result schema mismatch") + # Raw spool hashes may differ for unordered rows and ordered peers. + # The live full-row comparator supplies the value oracle; archive + # checksums bind its retained observations after those spools retire. + del pending[key] + if pending: + raise CampaignError("summary requires complete retained oracle pairs") + scales = {} + for scale in (1, 10): + routes = {} + for route in ROUTES: + values = [ + row + for row in records + if row["scale_factor"] == scale and row["route"] == route["id"] + ] + if campaign["resource_campaign"]: + routes[route["id"]] = {"resource_cells": len(values)} + continue + medians = { + f"Q{q}": statistics.median( + row["wall_seconds"] + for row in values + if row["phase"] == "performance" + and row["measured"] + and row["query"] == q + ) + for q in range(1, 23) + } + suite_times = [ + sum( + row["wall_seconds"] + for row in values + if row["phase"] == "performance" and row["suite"] == suite + ) + for suite in range(1, 6) + ] + q9 = [ + row["wall_seconds"] + for row in values + if row["phase"] == "q9-concurrency" + ] + routes[route["id"]] = { + "query_medians": medians, + "sum_query_medians": sum(medians.values()), + "median_full_suite": statistics.median(suite_times), + "suite_seconds": suite_times, + "q9_stress_median": statistics.median(q9), + "q9_seconds": q9, + } + scales[f"SF{scale}"] = routes + return { + "schema_version": 2, + "scales": scales, + "required_runs": len(records), + "acceptance_complete": False, + "remaining_gates": [ + "resource-allocation", + "lifecycle", + "sanitizer", + "numeric-baselines", + "required-CI", + ], + "flight_comparisons": [ + { + "id": profile["id"], + "coverage": profile["coverage"], + "status": "N/A" if not profile["coverage"] else "required", + } + for profile in campaign["flight_profiles"] + ], + } + + +def main_from_legacy(arguments, input_spec, runner): + """V2 always owns real services and enriches from their observed events.""" + from types import SimpleNamespace + from sirius_campaign import run_complete + + config = input_spec.get("controller", {}) + if ( + len(runner) != 1 + or not isinstance(config, dict) + or not {"workspace", "service"} <= set(config) + ): + raise CampaignError( + "v2 requires an owned service controller and one public runner binary" + ) + options = SimpleNamespace( + workspace=Path(config["workspace"]), + service=Path(config["service"]), + runner=Path(runner[0]), + output=arguments.output, + port_base=config.get("port_base", 36001), + resource=input_spec.get("resource_campaign", False), + ) + try: + run_complete(options, provenance=input_spec["provenance"]) + except (CampaignError, OSError, ValueError, subprocess.SubprocessError): + if (arguments.output / "failure.json").exists(): + return 1 + raise + return 0 diff --git a/optools/sirius_campaign_v2_test.py b/optools/sirius_campaign_v2_test.py new file mode 100644 index 0000000000000..adfe80ef9fd92 --- /dev/null +++ b/optools/sirius_campaign_v2_test.py @@ -0,0 +1,246 @@ +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +import hashlib +import json +import os +from pathlib import Path +import struct +import sys +import tempfile +import unittest + +import sirius_campaign_v2 as campaign + + +def provenance(): + return { + "matrixone_revision": "a" * 40, + "sirius_revision": "b" * 40, + "sdk_sha256": "c" * 64, + "query_sha256": {f"Q{q}": "d" * 64 for q in range(1, 23)}, + "datasets": {"SF1": "e" * 64, "SF10": "f" * 64}, + "hardware": { + "gpu_name": "test", + "gpu_uuid": "test", + "gpu_total_bytes": 8 << 30, + "cpu_model": "test", + "cpu_count": 4, + "host_total_bytes": 32 << 30, + }, + "toolchain": { + "go": "test", + "cxx": "test", + "cuda": "test", + "driver": "test", + "pixi_lock_sha256": "1" * 64, + "nsight": "test", + "gdb": "test", + }, + "config": { + "gpu_memory_bytes": 6 << 30, + "native_host_memory_bytes": 16 << 30, + "input_window_bytes": 64 << 20, + "result_window_bytes": 64 << 20, + "active_queries": 1, + "waiting_queries": 16, + "cleanup_timeout_seconds": 30, + "query_timeout_seconds": 3600, + "resource_timeout_seconds": 10800, + "session": { + "campaign_evidence": True, + "div_precision_increment": 4, + "time_zone": "+00:00", + }, + }, + "service_binary_sha256": "2" * 64, + "runner_binary_sha256": "3" * 64, + "sdk_build_mode": "release", + "controller_sha256": "4" * 64, + } + + +def output(root, name, values, *, oid=23, nullable=False): + schema = [ + { + "name": "key", + "sql_type": "BIGINT", + "nullable": nullable, + "nullable_known": True, + "precision": 0, + "scale": 0, + "decimal_known": False, + } + ] + bound = [{"oid": oid, "precision": 64, "scale": -1, "nullable": nullable}] + digest = hashlib.sha256(b"sirius-mo-output-schema-v1\0") + digest.update(struct.pack(" int: # Reject stale output before constructing or invoking a runner. _ensure_output_directory_available(arguments.output) spec = json.loads(arguments.spec.read_text(encoding="utf-8")) + if spec.get("schema_version", 1) == 2: + from sirius_campaign_v2 import main_from_legacy + return main_from_legacy(arguments, spec, runner) + if spec.get("schema_version", 1) != 1: + raise CampaignError("unsupported campaign schema version") campaign = build_campaign(spec["provenance"], spec.get("float_tolerances")) canonical_json(campaign).encode("utf-8") executor = SubprocessExecutor(runner, arguments.timeout_seconds) diff --git a/optools/sirius_profile_exit.gdb b/optools/sirius_profile_exit.gdb new file mode 100644 index 0000000000000..9b86ef0a2a124 --- /dev/null +++ b/optools/sirius_profile_exit.gdb @@ -0,0 +1,53 @@ +# Copyright 2026 Matrix Origin +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Allocation profiling only. Go's direct exit_group skips native atexit. +# Queries and the packaged service remain unchanged. Nonzero exits stay nonzero. +set pagination off +set confirm off +set startup-with-shell off +set auto-load off +set breakpoint pending on +handle SIGURG nostop noprint pass +handle SIGPIPE nostop noprint pass +handle SIGTERM nostop noprint pass +handle SIGINT nostop noprint pass +python +import gdb, re +class NativeExit(gdb.Breakpoint): + def stop(self): + # linux/amd64 ABI0 runtime.exit: mov code+0(FP),edi; SYS_exit_group. + gdb.execute("set language c") + instructions = gdb.selected_frame().architecture().disassemble(int(gdb.parse_and_eval("$pc")), count=3) + if len(instructions) != 3 or "0x8(%rsp),%edi" not in instructions[0]["asm"].replace(" ", "") or "$0xe7,%eax" not in instructions[1]["asm"].replace(" ", "") or instructions[2]["asm"] != "syscall": + raise gdb.GdbError("unsupported runtime exit ABI") + code = int(gdb.parse_and_eval("*(int*)($rsp+8)")) + print("verified runtime exit code:", code) + if code != 0: + return False + symbol = gdb.execute("info address exit", to_string=True) + address = re.search(r"0x[0-9a-f]+", symbol).group(0) + function = gdb.parse_and_eval("(void (*)(int)) " + address).dereference() + try: + function(code) + except gdb.error: + if gdb.selected_inferior().pid != 0: + raise + gdb.execute("quit " + str(code)) + return False +NativeExit("runtime.exit", internal=True) +end +run +python +gdb.execute("quit " + str(int(gdb.parse_and_eval("$_exitcode")))) +end diff --git a/optools/sirius_resource.py b/optools/sirius_resource.py new file mode 100644 index 0000000000000..b7cf8a51f543e --- /dev/null +++ b/optools/sirius_resource.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +# Copyright 2026 Matrix Origin +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Stream Nsight allocation records into safe physical-memory summaries.""" +import argparse +import json +from pathlib import Path +import sqlite3 +import tempfile +import os +import contextlib +import resource as posix_resource +import signal + +from sirius_campaign_v2 import CampaignError + + +MAX_LIVE_ALLOCATIONS = 65536 +MAX_TRACE_BYTES = 16 << 30 +MAX_LEDGER_BYTES = 4 << 30 +MAX_SORT_FILE_BYTES = 8 << 30 +MAX_ADAPTER_MEMORY_BYTES = 1 << 30 + + +@contextlib.contextmanager +def bounded_sqlite_sort(parent): + """Exclusive collector scope; bound SQLite's otherwise hidden sort files.""" + with tempfile.TemporaryDirectory(prefix="sirius-sort-", dir=parent) as directory: + previous_directory = os.environ.get("SQLITE_TMPDIR") + previous_signal = signal.signal(signal.SIGXFSZ, signal.SIG_IGN) + changed = [] + try: + for kind, cap in ( + (posix_resource.RLIMIT_FSIZE, MAX_SORT_FILE_BYTES), + (posix_resource.RLIMIT_AS, MAX_ADAPTER_MEMORY_BYTES), + ): + previous = posix_resource.getrlimit(kind) + soft = ( + cap + if previous[0] == posix_resource.RLIM_INFINITY + else min(cap, previous[0]) + ) + posix_resource.setrlimit(kind, (soft, previous[1])) + changed.append((kind, previous)) + os.environ["SQLITE_TMPDIR"] = directory + yield Path(directory) + finally: + if previous_directory is None: + os.environ.pop("SQLITE_TMPDIR", None) + else: + os.environ["SQLITE_TMPDIR"] = previous_directory + try: + for kind, previous in reversed(changed): + posix_resource.setrlimit(kind, previous) + finally: + signal.signal(signal.SIGXFSZ, previous_signal) + + +class AllocationLedger: + """Small live map, spilling exact generations to a private bounded database.""" + + def __init__(self, parent): + self.memory = {} + self.directory = self.database = None + self.parent = parent + self.operations = 0 + + def _spill(self): + self.directory = tempfile.TemporaryDirectory( + prefix="sirius-ledger-", dir=self.parent + ) + path = Path(self.directory.name) / "live.sqlite" + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + os.close(descriptor) + self.database = sqlite3.connect(path) + self.database.execute("pragma cache_size=-8192") + self.database.execute("pragma mmap_size=0") + self.database.execute("pragma journal_mode=off") + self.database.execute("pragma synchronous=off") + page_size = self.database.execute("pragma page_size").fetchone()[0] + max_pages = MAX_LEDGER_BYTES // page_size + if max_pages < 2: + raise CampaignError("allocation ledger scratch quota exceeded") + self.database.execute(f"pragma max_page_count={max_pages}") + try: + self.database.execute( + "create table live(context text,address text,category text,bytes integer,primary key(context,address,category)) without rowid" + ) + self.database.executemany( + "insert into live values(?,?,?,?)", + ((*self._key(key), value) for key, value in self.memory.items()), + ) + except sqlite3.OperationalError as error: + if error.sqlite_errorcode == sqlite3.SQLITE_FULL: + raise CampaignError( + "allocation ledger scratch quota exceeded" + ) from error + raise + self.memory.clear() + + @staticmethod + def _key(key): + return tuple(str(value) for value in key) + + def _quota(self): + self.operations += 1 + if self.operations % 4096 == 0: + pages = self.database.execute("pragma page_count").fetchone()[0] + page_size = self.database.execute("pragma page_size").fetchone()[0] + if pages * page_size > MAX_LEDGER_BYTES: + raise CampaignError("allocation ledger scratch quota exceeded") + + def add(self, key, size): + if self.database is None: + if key in self.memory: + raise CampaignError("allocation generation was reused before free") + if len(self.memory) < MAX_LIVE_ALLOCATIONS: + self.memory[key] = size + return + self._spill() + try: + self.database.execute( + "insert into live values(?,?,?,?)", (*self._key(key), size) + ) + except sqlite3.IntegrityError as error: + raise CampaignError( + "allocation generation was reused before free" + ) from error + except sqlite3.OperationalError as error: + if error.sqlite_errorcode == sqlite3.SQLITE_FULL: + raise CampaignError( + "allocation ledger scratch quota exceeded" + ) from error + raise + self._quota() + + def release(self, key): + if self.database is None: + if key not in self.memory: + raise CampaignError("allocation trace lacks matching acquisition") + return self.memory.pop(key) + encoded = self._key(key) + row = self.database.execute( + "select bytes from live where context=? and address=? and category=?", + encoded, + ).fetchone() + if row is None: + raise CampaignError("allocation trace lacks matching acquisition") + self.database.execute( + "delete from live where context=? and address=? and category=?", encoded + ) + self._quota() + return row[0] + + def close(self): + try: + if self.database is not None: + self.database.close() + finally: + if self.directory is not None: + self.directory.cleanup() + + +def allocation_summary(path, pid, runs=()): + with bounded_sqlite_sort(Path(path).resolve().parent): + return _allocation_summary(path, pid, runs) + + +def _allocation_summary(path, pid, runs): + if type(pid) is not int or pid <= 0 or Path(path).stat().st_size > MAX_TRACE_BYTES: + raise CampaignError("allocation trace identity or quota invalid") + connection = sqlite3.connect(f"file:{Path(path).resolve()}?mode=ro", uri=True) + live = AllocationLedger(Path(path).resolve().parent) + try: + connection.execute("pragma cache_size=-8192") + connection.execute("pragma mmap_size=0") + # Single-threaded SQLite sorting uses a main and auxiliary PMA file. + # Both have the OS file cap above and participate in the reserved quota. + connection.execute("pragma threads=0") + connection.execute("pragma temp_store=file") + tables = { + row[0] + for row in connection.execute( + "select name from sqlite_master where type='table'" + ) + } + if ( + not { + "CUDA_GPU_MEMORY_USAGE_EVENTS", + "ENUM_CUDA_MEM_KIND", + "ENUM_CUDA_DEV_MEM_EVENT_OPER", + } + <= tables + ): + raise CampaignError("required allocation trace tables unavailable") + if ( + "DIAGNOSTIC_EVENT" in tables + and connection.execute( + "select 1 from DIAGNOSTIC_EVENT where severity>=2 and (text like '%CUDA%' or text like '%CUPTI%') and (globalPid is null or globalPid<=0 or ((globalPid>>24)&16777215)=?) limit 1", + (pid,), + ).fetchone() + ): + raise CampaignError("CUDA allocation trace reports incomplete collection") + kinds = { + row[0]: row[1] + for row in connection.execute("select id,label from ENUM_CUDA_MEM_KIND") + } + operations = { + row[0]: row[1] + for row in connection.execute( + "select id,label from ENUM_CUDA_DEV_MEM_EVENT_OPER" + ) + } + totals = {"pinned": 0, "device": 0} + peaks = totals.copy() + counts = totals.copy() + first = last = None + spans = [] + for run in runs: + if run["backend"] != "embedded": + continue + span = run.get("resource_span", {}) + if ( + span.get("time_domain") != "unix-epoch-ns" + or span.get("scope") != "runner-lifetime" + or type(span.get("start_ns")) is not int + or type(span.get("end_ns")) is not int + or span["start_ns"] >= span["end_ns"] + or spans + and span["start_ns"] < spans[-1]["end_ns"] + ): + raise CampaignError("invalid or overlapping resource observation span") + spans.append( + { + **span, + "sequence": run["sequence"], + "query_id": run["query_id"], + "phase": run["phase"], + "before_bytes": None, + "peak_bytes": None, + "after_bytes": None, + } + ) + if len(spans) > 1024: + raise CampaignError("resource span count exceeds bound") + span_index = 0 + query = "select start,globalPid,contextId,address,bytes,memKind,memoryOperationType from CUDA_GPU_MEMORY_USAGE_EVENTS where ((globalPid >> 24) & 16777215)=? order by start" + for ( + timestamp, + global_pid, + context, + address, + size, + kind, + operation, + ) in connection.execute(query, (pid,)): + # Nsight GlobalId packs PID at bit 24; no process-name attribution. + if (global_pid >> 24) & 0xFFFFFF != pid: + continue + label = str(kinds.get(kind, "")).upper() + category = ( + "pinned" + if "PINNED" in label + else "device" if "DEVICE" in label or "ARRAY" in label else None + ) + if category is None: + continue + while span_index < len(spans) and timestamp > spans[span_index]["end_ns"]: + span = spans[span_index] + if span["before_bytes"] is None: + span["before_bytes"], span["peak_bytes"] = ( + totals.copy(), + totals.copy(), + ) + span["after_bytes"] = totals.copy() + span_index += 1 + if ( + span_index < len(spans) + and timestamp >= spans[span_index]["start_ns"] + and spans[span_index]["before_bytes"] is None + ): + spans[span_index]["before_bytes"], spans[span_index]["peak_bytes"] = ( + totals.copy(), + totals.copy(), + ) + if type(size) is not int or size < 0: + raise CampaignError("invalid traced allocation size") + if (context is not None and type(context) is not int) or type( + address + ) is not int: + raise CampaignError("invalid traced allocation identity") + key = (context, address, category) + action = str(operations.get(operation, "")).upper() + if "FREE" in action or "DEALLOC" in action: + totals[category] -= live.release(key) + elif "ALLOC" in action: + live.add(key, size) + totals[category] += size + peaks[category] = max(peaks[category], totals[category]) + counts[category] += 1 + else: + raise CampaignError("unknown allocation operation") + first = timestamp if first is None else first + last = timestamp + if ( + span_index < len(spans) + and spans[span_index]["before_bytes"] is not None + ): + span = spans[span_index] + for key in totals: + span["peak_bytes"][key] = max(span["peak_bytes"][key], totals[key]) + if any(counts[key] == 0 for key in totals): + raise CampaignError("pinned or GPU allocation evidence unavailable") + if spans: + try: + capture_start = connection.execute( + "select utcEpochNs from TARGET_INFO_SESSION_START_TIME" + ).fetchone()[0] + duration = connection.execute( + "select duration from ANALYSIS_DETAILS" + ).fetchone()[0] + except (sqlite3.Error, TypeError) as error: + raise CampaignError( + "allocation trace lacks capture clock coverage" + ) from error + if ( + not capture_start + <= spans[0]["start_ns"] + <= spans[-1]["end_ns"] + <= capture_start + duration + or first < capture_start + or last > capture_start + duration + ): + raise CampaignError( + "allocation trace does not bracket the resource runs" + ) + while span_index < len(spans): + span = spans[span_index] + if span["before_bytes"] is None: + span["before_bytes"], span["peak_bytes"] = totals.copy(), totals.copy() + span["after_bytes"] = totals.copy() + span_index += 1 + return { + "source": "nsight-cupti-memory-events", + "scope": "cn-process-lifetime", + "pid": pid, + "units": "bytes", + "start_ns": first, + "end_ns": last, + "allocation_counts": counts, + "peak_bytes": peaks, + "final_bytes": totals, + "runs": spans, + } + finally: + try: + live.close() + finally: + connection.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("sqlite", type=Path) + parser.add_argument("--pid", type=int, required=True) + args = parser.parse_args() + print(json.dumps(allocation_summary(args.sqlite, args.pid), sort_keys=True)) diff --git a/optools/ut_schedule_test.go b/optools/ut_schedule_test.go index 13ce090d9d09a..f59eee57481a7 100644 --- a/optools/ut_schedule_test.go +++ b/optools/ut_schedule_test.go @@ -1533,7 +1533,8 @@ read -r observed <&8 wait "$child_pid" printf 'CHILD %s %s\n' "$observed" "$child_pid" handled=0 -trap 'fixture_self_pid trap_pid; [[ "$trap_pid" == "$main" ]] || exit 97; handled=$((handled+1)); printf "ack\n" >&7' TERM +ack_pending=0 +trap 'fixture_self_pid trap_pid; [[ "$trap_pid" == "$main" ]] || exit 97; handled=$((handled+1)); ack_pending=1' TERM ( for ((j=0; j<200; j++)); do kill -TERM "$main" @@ -1552,8 +1553,16 @@ while [[ ! -f "$scratch/done" ]]; do fixture_self_pid observed [[ "$observed" == "$main" ]] || exit 99 fi + # Acknowledge only after the handler returns. Otherwise the sender can + # deliver the next TERM while TERM is still blocked inside the handler, + # and Bash can coalesce it with the signal already being handled. + if (( ack_pending )); then + ack_pending=0 + printf 'ack\n' >&7 + fi done wait "$sender" +trap - EXIT printf 'TERMS %s\n' "$handled" ` root := t.TempDir() diff --git a/pkg/cnservice/sirius_schema.go b/pkg/cnservice/sirius_schema.go index 5a241092c09db..c5b1471c736ca 100644 --- a/pkg/cnservice/sirius_schema.go +++ b/pkg/cnservice/sirius_schema.go @@ -15,40 +15,21 @@ package cnservice import ( - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "github.com/matrixorigin/matrixone/pkg/sql/compile" + "github.com/matrixorigin/matrixone/pkg/sql/compile/siriusbridge" ) -type embeddedColumnEvidence struct { - OID int32 `json:"oid"` - Precision int32 `json:"precision"` - Scale int32 `json:"scale"` - Nullable bool `json:"nullable"` -} +type embeddedColumnEvidence = siriusbridge.ColumnEvidence // Preparation verified these descriptors against the native output schema. // The bridge bounds them to 1024 columns before an execution can exist. Names // participate only in the digest: log neither expression headings nor values. func embeddedSchemaEvidence(request compile.SiriusPrepareRequest) ([]embeddedColumnEvidence, string) { - columns := make([]embeddedColumnEvidence, len(request.OutputTypes)) - digest := sha256.New() - _, _ = digest.Write([]byte("sirius-mo-output-schema-v1\x00")) - var descriptor [17]byte + columns := make([]siriusbridge.Column, len(request.OutputTypes)) for i, typ := range request.OutputTypes { - columns[i] = embeddedColumnEvidence{OID: typ.Id, Precision: typ.Width, Scale: typ.Scale, Nullable: !typ.NotNullable} - binary.LittleEndian.PutUint32(descriptor[:4], uint32(typ.Id)) - binary.LittleEndian.PutUint32(descriptor[4:8], uint32(typ.Width)) - binary.LittleEndian.PutUint32(descriptor[8:12], uint32(typ.Scale)) - descriptor[12] = 0 - if !typ.NotNullable { - descriptor[12] = 1 - } - binary.LittleEndian.PutUint32(descriptor[13:], uint32(len(request.Headings[i]))) - _, _ = digest.Write(descriptor[:]) - _, _ = digest.Write([]byte(request.Headings[i])) + columns[i] = siriusbridge.Column{OID: uint32(typ.Id), Width: typ.Width, Scale: typ.Scale, Nullable: !typ.NotNullable, Name: request.Headings[i]} } - return columns, hex.EncodeToString(digest.Sum(nil)) + // Preparation already validated these immutable descriptor bounds. + evidence, digest, _ := siriusbridge.SchemaEvidence(columns) + return evidence, digest } diff --git a/pkg/frontend/campaign_schema_evidence.go b/pkg/frontend/campaign_schema_evidence.go new file mode 100644 index 0000000000000..4b0cd61481d1c --- /dev/null +++ b/pkg/frontend/campaign_schema_evidence.go @@ -0,0 +1,51 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package frontend + +import ( + "github.com/matrixorigin/matrixone/pkg/logutil" + "github.com/matrixorigin/matrixone/pkg/pb/plan" + "github.com/matrixorigin/matrixone/pkg/sql/compile/siriusbridge" + "go.uber.org/zap" +) + +// Called only by the once-per-statement executed metadata publication owner, +// after a definition retry has installed and frozen the producing generation. +func recordCampaignResultMetadata(ses *Session, columns []*plan.ColDef) { + if !ses.campaignEvidenceEnabled.Load() { + return + } + if len(columns) == 0 || len(columns) > 1024 { + return + } + nameBytes := 0 + for _, column := range columns { + if column == nil || len(column.Name) > (1<<20)-nameBytes { + return + } + nameBytes += len(column.Name) + } + descriptors := make([]siriusbridge.Column, len(columns)) + for index, column := range columns { + descriptors[index] = siriusbridge.Column{OID: uint32(column.Typ.Id), Width: column.Typ.Width, Scale: column.Typ.Scale, Nullable: !column.Typ.NotNullable, Name: column.Name} + } + evidence, digest, err := siriusbridge.SchemaEvidence(descriptors) + if err != nil { + return + } + logutil.Info("MO campaign result schema", + zap.String("query_id", ses.GetStmtId().String()), zap.Uint32("account_id", ses.GetAccountId()), + zap.Any("output_schema", evidence), zap.String("output_schema_sha256", digest)) +} diff --git a/pkg/frontend/campaign_schema_evidence_test.go b/pkg/frontend/campaign_schema_evidence_test.go new file mode 100644 index 0000000000000..12e38d21e27d2 --- /dev/null +++ b/pkg/frontend/campaign_schema_evidence_test.go @@ -0,0 +1,97 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package frontend + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/matrixorigin/matrixone/pkg/container/types" + "github.com/matrixorigin/matrixone/pkg/logutil" + "github.com/matrixorigin/matrixone/pkg/pb/plan" + "github.com/matrixorigin/matrixone/pkg/sql/compile/siriusbridge" + "github.com/stretchr/testify/require" + "go.uber.org/zap/buffer" + "go.uber.org/zap/zapcore" +) + +func TestCampaignEvidenceSessionScopeAndConversion(t *testing.T) { + ctrl := gomock.NewController(t) + ses := newTestSession(t, ctrl) + defer ses.Close() + require.False(t, ses.campaignEvidenceEnabled.Load()) + for _, setting := range []any{int64(1), "ON", true} { + require.NoError(t, ses.SetSessionSysVar(context.Background(), "mo_sirius_campaign_evidence", setting)) + require.True(t, ses.campaignEvidenceEnabled.Load()) + } + require.Error(t, ses.SetSessionSysVar(context.Background(), "mo_sirius_campaign_evidence", int64(2))) + require.True(t, ses.campaignEvidenceEnabled.Load(), "rejected SET cannot change the cached flag") + require.NoError(t, ses.SetSessionSysVar(context.Background(), "mo_sirius_campaign_evidence", int64(0))) + require.False(t, ses.campaignEvidenceEnabled.Load()) + require.Equal(t, ScopeSession, gSysVarsDefs["mo_sirius_campaign_evidence"].Scope) + require.False(t, gSysVarsDefs["mo_sirius_campaign_evidence"].SetVarHintApplies) +} + +func TestCampaignMetadataRejectsInvalidDescriptorsAndKeepsHeadingsPrivate(t *testing.T) { + ses := newTestSession(t, gomock.NewController(t)) + t.Cleanup(ses.Close) + ses.SetStmtId(uuid.New()) + var mu sync.Mutex + var events []map[string]any + previous := logutil.GetReportZapFunc() + logutil.SetLogReporter(&logutil.TraceReporter{ReportZap: func(encoder zapcore.Encoder, entry zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + if entry.Message == "MO campaign result schema" { + object := zapcore.NewMapObjectEncoder() + for _, field := range fields { + field.AddTo(object) + } + mu.Lock() + events = append(events, object.Fields) + mu.Unlock() + } + return previous(encoder, entry, fields) + }}) + t.Cleanup(func() { logutil.SetLogReporter(&logutil.TraceReporter{ReportZap: previous}) }) + column := &plan.ColDef{Name: "private heading", Typ: plan.Type{Id: int32(types.T_decimal256), Width: 8, Scale: 2}} + recordCampaignResultMetadata(ses, []*plan.ColDef{column}) + ses.campaignEvidenceEnabled.Store(true) + for _, columns := range [][]*plan.ColDef{nil, make([]*plan.ColDef, 1025), {nil}, {{Name: strings.Repeat("x", (1<<20)+1)}}, {{Typ: plan.Type{Id: -1}}}} { + recordCampaignResultMetadata(ses, columns) + } + snapshot := func() []map[string]any { + mu.Lock() + defer mu.Unlock() + return append([]map[string]any(nil), events...) + } + require.Empty(t, snapshot(), "disabled or invalid metadata cannot produce evidence") + recordCampaignResultMetadata(ses, []*plan.ColDef{column}) + observed := snapshot() + require.Len(t, observed, 1) + event := observed[0] + require.Equal(t, ses.GetStmtId().String(), event["query_id"]) + require.EqualValues(t, ses.GetAccountId(), event["account_id"]) + require.Equal(t, []siriusbridge.ColumnEvidence{{OID: int32(types.T_decimal256), Precision: 8, Scale: 2, Nullable: true}}, event["output_schema"]) + encoded, err := json.Marshal(event) + require.NoError(t, err) + require.NotContains(t, string(encoded), column.Name) + _, expectedDigest, err := siriusbridge.SchemaEvidence([]siriusbridge.Column{{OID: uint32(types.T_decimal256), Width: 8, Scale: 2, Nullable: true, Name: column.Name}}) + require.NoError(t, err) + require.Equal(t, expectedDigest, event["output_schema_sha256"]) +} diff --git a/pkg/frontend/result_row_stmt.go b/pkg/frontend/result_row_stmt.go index 4deaf87bae4a4..d74adf0e81bd8 100644 --- a/pkg/frontend/result_row_stmt.go +++ b/pkg/frontend/result_row_stmt.go @@ -354,6 +354,7 @@ func publishSelectResultMetadata(ses *Session, execCtx *ExecCtx) error { } ses.rs = &plan.ResultColDef{ResultCols: colDefs} freezeResultMetadata(execCtx.runner) + recordCampaignResultMetadata(ses, colDefs) return execCtx.resper.RespPreMeta(execCtx, columns) } diff --git a/pkg/frontend/returning_spool_test.go b/pkg/frontend/returning_spool_test.go index 4ca20f47b59ae..1b53c5d4262af 100644 --- a/pkg/frontend/returning_spool_test.go +++ b/pkg/frontend/returning_spool_test.go @@ -35,6 +35,7 @@ import ( "github.com/matrixorigin/matrixone/pkg/pb/plan" "github.com/matrixorigin/matrixone/pkg/perfcounter" "github.com/matrixorigin/matrixone/pkg/util/resource" + "github.com/matrixorigin/matrixone/pkg/vm/process" ) type returningProtocolRecorder struct { @@ -176,9 +177,22 @@ func returningTestBatch(t *testing.T, ses *Session, values ...int64) *batch.Batc return bat } +func newReturningSpoolSession(t *testing.T) *Session { + t.Helper() + ses := newValidateSession(t) + // Spool ownership tests need a private fixed ledger. Ambient CN headroom + // and another fixture's shared cap must not change their failure boundary. + budget := process.MustNewExecutionResourceBudget(1<<20, 1<<20) + generation, err := budget.OpenGeneration(1) + require.NoError(t, err) + t.Cleanup(generation.Close) + require.NoError(t, ses.proc.SetExecutionResourceBudgetForTesting(generation)) + return ses +} + func TestReturningSpoolGenerationAndReplay(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) spool := &returningSpool{} budget, err := ses.proc.GetExecutionResourceBudget() require.NoError(t, err) @@ -232,7 +246,7 @@ func TestReturningSpoolGenerationAndReplay(t *testing.T) { func TestDeferredReturningResponseStartsAfterPublish(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) ses.txnHandler = &TxnHandler{} ses.SetMysqlResultSet(&MysqlResultSet{}) @@ -275,7 +289,7 @@ func TestDeferredReturningResponseStartsAfterPublish(t *testing.T) { func TestDeferredReturningReplayOutputWaitStaysRootOwned(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) ses.txnHandler = &TxnHandler{} ses.SetMysqlResultSet(&MysqlResultSet{}) @@ -318,7 +332,7 @@ func TestDeferredReturningReplayOutputWaitStaysRootOwned(t *testing.T) { func TestDeferredReturningZeroRowsStillSendsMetadata(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) ses.txnHandler = &TxnHandler{} ses.SetMysqlResultSet(&MysqlResultSet{}) @@ -349,7 +363,7 @@ func TestDeferredReturningZeroRowsStillSendsMetadata(t *testing.T) { func TestDeferredReturningPublishFailureDoesNotHideCommittedRows(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) ses.txnHandler = &TxnHandler{} ses.SetMysqlResultSet(&MysqlResultSet{}) @@ -389,7 +403,7 @@ func TestDeferredReturningPublishFailureDoesNotHideCommittedRows(t *testing.T) { func TestReturningCommitFailureAbortsStageBeforeProtocol(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) var events []string state := &returningState{ spool: &returningSpool{}, @@ -405,7 +419,7 @@ func TestReturningCommitFailureAbortsStageBeforeProtocol(t *testing.T) { func TestDeferredReturningClientDisconnectCleansSpool(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) ses.txnHandler = &TxnHandler{} ses.SetMysqlResultSet(&MysqlResultSet{}) budget, err := ses.proc.GetExecutionResourceBudget() @@ -443,7 +457,7 @@ func TestDeferredReturningClientDisconnectCleansSpool(t *testing.T) { func TestReturningQueryResultStagesPhysicalZeroRowBlock(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) installReturningAccountingFileService(t, ses) root := resource.NewRoot(resource.ConnExternal) ctx = resource.ContextWithRoot(ctx, root) @@ -473,7 +487,7 @@ func TestReturningQueryResultStagesPhysicalZeroRowBlock(t *testing.T) { func TestReturningQueryResultPublishesStageAndMetadataAccounting(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) installReturningAccountingFileService(t, ses) root := resource.NewRoot(resource.ConnExternal) ctx = resource.ContextWithRoot(ctx, root) @@ -506,7 +520,7 @@ func TestReturningQueryResultPublishesStageAndMetadataAccounting(t *testing.T) { func TestReturningQueryResultPublishFailureAccountsAbort(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) accountingFS := installReturningAccountingFileService(t, ses) root := resource.NewRoot(resource.ConnExternal) ctx = resource.ContextWithRoot(ctx, root) @@ -536,7 +550,7 @@ func TestReturningQueryResultPublishFailureAccountsAbort(t *testing.T) { func TestReturningQueryResultStageFailureAccountsAbort(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) accountingFS := installReturningAccountingFileService(t, ses) accountingFS.failWrites = true root := resource.NewRoot(resource.ConnExternal) @@ -565,7 +579,7 @@ func TestReturningQueryResultStageFailureAccountsAbort(t *testing.T) { func TestReturningQueryResultAbortRetryAccountsEveryAttempt(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) accountingFS := installReturningAccountingFileService(t, ses) root := resource.NewRoot(resource.ConnExternal) ctx = resource.ContextWithRoot(ctx, root) @@ -599,7 +613,7 @@ func TestReturningQueryResultAbortRetryAccountsEveryAttempt(t *testing.T) { func TestReturningSpoolRejectsTruncatedRecord(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) spool := &returningSpool{} defer func() { require.NoError(t, spool.Close()) }() bat := returningTestBatch(t, ses, 1, 2) @@ -616,7 +630,7 @@ func TestReturningSpoolRejectsTruncatedRecord(t *testing.T) { func TestReturningSpoolRejectsCorruptMagic(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) spool := &returningSpool{} defer func() { require.NoError(t, spool.Close()) }() bat := returningTestBatch(t, ses, 1) @@ -644,7 +658,7 @@ func TestReturningSpoolRejectsCorruptMagic(t *testing.T) { func TestReturningSpoolReplayCancellationReleasesDecodeBudget(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) budget, err := ses.proc.GetExecutionResourceBudget() require.NoError(t, err) spool := &returningSpool{} @@ -664,7 +678,7 @@ func TestReturningSpoolReplayCancellationReleasesDecodeBudget(t *testing.T) { func TestReturningSpoolConcurrentTerminalTransitions(t *testing.T) { ctx := context.Background() - ses := newValidateSession(t) + ses := newReturningSpoolSession(t) budget, err := ses.proc.GetExecutionResourceBudget() require.NoError(t, err) for i := 0; i < 16; i++ { diff --git a/pkg/frontend/session.go b/pkg/frontend/session.go index 36a33814c02ee..a93a702ac36b4 100644 --- a/pkg/frontend/session.go +++ b/pkg/frontend/session.go @@ -287,6 +287,8 @@ type Session struct { // rewriteEnabled caches the enable_remap_hint system variable state // to avoid expensive GetSessionSysVar calls on every SQL query rewriteEnabled atomic.Bool + // Result-schema evidence is opt-in and costs one atomic read when disabled. + campaignEvidenceEnabled atomic.Bool mu sync.Mutex @@ -525,6 +527,12 @@ func (ses *Session) initSystemVariablesFromGlobal(ctx context.Context, sv *Syste txnHandler := ses.txnHandler ses.mu.Unlock() atomic.StoreInt32(&ses.sqlModeNoAutoValueOnZero, -1) + ses.campaignEvidenceEnabled.Store(false) + if v := sessionVars.Get("mo_sirius_campaign_evidence"); v != nil { + if on, convErr := valueIsBoolTrue(v); convErr == nil { + ses.campaignEvidenceEnabled.Store(on) + } + } // Initialize rewriteEnabled cache if v := sessionVars.Get("enable_remap_hint"); v != nil { diff --git a/pkg/frontend/types.go b/pkg/frontend/types.go index 8f2bb125285f4..4b42228bbbea3 100644 --- a/pkg/frontend/types.go +++ b/pkg/frontend/types.go @@ -1969,6 +1969,9 @@ func (ses *Session) setSessionSysVar(ctx context.Context, name string, val inter } else { ses.sesSysVars.Set(canonicalName, val) } + if err == nil && name == "mo_sirius_campaign_evidence" { + ses.campaignEvidenceEnabled.Store(val.(int8) != 0) + } if err == nil && name == "sql_mode" { ses.updateSqlModeNoAutoValueOnZero(val) } diff --git a/pkg/frontend/variables.go b/pkg/frontend/variables.go index 946aa22773971..56afe321cd9c1 100644 --- a/pkg/frontend/variables.go +++ b/pkg/frontend/variables.go @@ -3862,6 +3862,14 @@ var gSysVarsDefs = map[string]SystemVariable{ // TODO(retire-txn-trace, #29249): remove this declaration and its inert-setting // tests once the rollback window excludes collector-bearing versions and // client/session initialization no longer sends this setting. + "mo_sirius_campaign_evidence": { + Name: "mo_sirius_campaign_evidence", + Scope: ScopeSession, + Dynamic: true, + SetVarHintApplies: false, + Type: InitSystemVariableBoolType("mo_sirius_campaign_evidence"), + Default: int64(0), + }, "disable_txn_trace": { Name: "disable_txn_trace", Scope: ScopeSession, diff --git a/pkg/sql/colexec/partition/topn_test.go b/pkg/sql/colexec/partition/topn_test.go index c78cb89b4f933..a1875f47c004f 100644 --- a/pkg/sql/colexec/partition/topn_test.go +++ b/pkg/sql/colexec/partition/topn_test.go @@ -129,15 +129,33 @@ func TestPartitionTopNLiteralOrderKeys(t *testing.T) { } func TestPartitionTopNRejectsUnsupportedOrderType(t *testing.T) { - proc := testutil.NewProcessWithOwnedMPool(t, "", mpool.MustNewZero()) - t.Cleanup(func() { require.Zero(t, proc.Mp().CurrNB()) }) - t.Cleanup(proc.Free) - arg := newTopNArgument(1) - t.Cleanup(func() { arg.Free(proc, true, nil) }) - arg.OrderBySpecs = arg.OrderBySpecs[:2] - arg.OrderBySpecs[1].Expr = topNCol(1, types.T_any) - err := arg.Prepare(proc) - require.True(t, moerr.IsMoErrCode(err, moerr.ErrNotSupported), "%v", err) + for _, tc := range []struct { + name string + oid types.T + supported bool + }{ + {"tuple", types.T_tuple, false}, + {"interval", types.T_interval, false}, + // ANY is supported for domainless NULL values; it cannot serve as + // the unsupported-type counterexample. + {"domainless_null", types.T_any, true}, + } { + t.Run(tc.name, func(t *testing.T) { + proc := testutil.NewProcessWithOwnedMPool(t, "", mpool.MustNewZero()) + t.Cleanup(func() { require.Zero(t, proc.Mp().CurrNB()) }) + t.Cleanup(proc.Free) + arg := newTopNArgument(1) + t.Cleanup(func() { arg.Free(proc, true, nil) }) + arg.OrderBySpecs = arg.OrderBySpecs[:2] + arg.OrderBySpecs[1].Expr = topNCol(1, tc.oid) + err := arg.Prepare(proc) + if tc.supported { + require.NoError(t, err) + } else { + require.True(t, moerr.IsMoErrCode(err, moerr.ErrNotSupported), "%v", err) + } + }) + } } func TestPartitionTopNSQLOrderFloatNaNs(t *testing.T) { diff --git a/pkg/sql/compile/ddl_test.go b/pkg/sql/compile/ddl_test.go index e59403ca4ba21..51dd9a5b90697 100644 --- a/pkg/sql/compile/ddl_test.go +++ b/pkg/sql/compile/ddl_test.go @@ -3596,7 +3596,11 @@ func TestTruncateLifecycleRCWaitPolicy(t *testing.T) { }).AnyTimes() op.EXPECT().TxnOptions().Return(txn.TxnOptions{}).AnyTimes() op.EXPECT().CreateTS().Return(timestamp.Timestamp{}).AnyTimes() - op.EXPECT().HasLockTable(registryID).Return(tc.retained) + var retained []lock.LockTable + if tc.retained { + retained = []lock.LockTable{{Table: registryID, Valid: true}} + } + op.EXPECT().Snapshot().Return(txn.CNTxnSnapshot{LockTables: retained}, nil) var pending atomic.Int32 op.EXPECT().AddWaitLock(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( func(uint64, [][]byte, lock.LockOptions) uint64 { pending.Add(1); return 1 }).AnyTimes() diff --git a/pkg/sql/compile/sirius_embedded_admission_test.go b/pkg/sql/compile/sirius_embedded_admission_test.go index bc6de22b9d8ce..01db44b617748 100644 --- a/pkg/sql/compile/sirius_embedded_admission_test.go +++ b/pkg/sql/compile/sirius_embedded_admission_test.go @@ -109,6 +109,34 @@ func TestEmbeddedSiriusAdmissionBindsWithoutStartingReaders(t *testing.T) { } require.Zero(t, eng.buildBlockReadersCalls) } + t.Run("shared input occurrences", func(t *testing.T) { + shared := &planpb.Query{StmtType: planpb.Query_SELECT, Headings: []string{"left", "right"}, Steps: []int32{1, 4}, Nodes: []*planpb.Node{ + query.Nodes[0], + {NodeId: 1, NodeType: planpb.Node_SINK, Children: []int32{0}}, + {NodeId: 2, NodeType: planpb.Node_SINK_SCAN, SourceStep: []int32{0}}, + {NodeId: 3, NodeType: planpb.Node_SINK_SCAN, SourceStep: []int32{0}}, + {NodeId: 4, NodeType: planpb.Node_JOIN, JoinType: planpb.Node_INNER, Children: []int32{2, 3}}, + }} + sharedPlan := &planpb.Plan{Plan: &planpb.Plan_Query{Query: shared}} + backend.request = SiriusPrepareRequest{} + offloaded, err := c.compileEmbeddedSiriusRead(ctx, sharedPlan, runtime) + require.NoError(t, err) + require.True(t, offloaded) + require.Len(t, backend.request.Reads, 2) + for i, read := range backend.request.Reads { + require.Equal(t, uint64(i+1), read.BindingID) + require.NotNil(t, read.Producer) + require.Equal(t, "t", read.Table) + require.Equal(t, int32(types.T_decimal256), read.Columns[0].Type.Id) + } + require.Zero(t, eng.buildBlockReadersCalls) + require.Equal(t, []int32{1, 4}, shared.Steps) + require.Len(t, shared.Nodes, 5) + var sharedWire spb.Plan + require.NoError(t, proto.Unmarshal(backend.request.Plan, &sharedWire)) + require.Len(t, sharedWire.Relations, 1) + c.siriusRead = nil + }) backend.err = errors.New("native prepare rejected") offloaded, err = c.compileEmbeddedSiriusRead(ctx, plan, runtime) require.ErrorIs(t, err, backend.err) diff --git a/pkg/sql/compile/sirius_embedded_read.go b/pkg/sql/compile/sirius_embedded_read.go index 75cc0ee3fe141..674535bc69504 100644 --- a/pkg/sql/compile/sirius_embedded_read.go +++ b/pkg/sql/compile/sirius_embedded_read.go @@ -36,10 +36,22 @@ func (c *Compile) compileEmbeddedSiriusRead(ctx context.Context, queryPlan *plan return false, moerr.NewNotSupported(ctx, "statement is not eligible for embedded Sirius") } capabilities := runtime.Backend.Capabilities() - candidate, err := substrait.ExportEmbeddedMO(queryPlan.GetQuery(), substrait.NewEmbeddedExportProfile(capabilities)) + query := queryPlan.GetQuery() + profile := substrait.NewEmbeddedExportProfile(capabilities) + candidate, err := substrait.ExportEmbeddedMO(query, profile) if err != nil { return false, err // explicit selection never falls back } + if len(query.Steps) > 1 { + query, err = substrait.ExpandEmbeddedMOSteps(query) + if err != nil { + return false, err + } + candidate, err = substrait.ExportEmbeddedMO(query, profile) + if err != nil { + return false, err + } + } reads, err := candidate.EmbeddedMOReads() if err != nil { return false, err @@ -70,7 +82,7 @@ func (c *Compile) compileEmbeddedSiriusRead(ctx context.Context, queryPlan *plan spec := siriusReaderSpec{ parent: c.proc, e: c.e, addr: c.addr, db: c.db, sql: c.sql, tenant: c.tenant, uid: c.uid, txnOffset: c.TxnOffset, - ncpu: max(1, c.ncpu), node: plan2.DeepCopyNode(queryPlan.GetQuery().Nodes[read.NodeID]), + ncpu: max(1, c.ncpu), node: plan2.DeepCopyNode(query.Nodes[read.NodeID]), columns: columns, } descriptors[i] = SiriusReadDescriptor{BindingID: binding, Database: read.Database, Table: read.Table, Schema: read.Schema, Columns: columns, Producer: spec.run} diff --git a/pkg/sql/compile/siriusbridge/schema_evidence.go b/pkg/sql/compile/siriusbridge/schema_evidence.go new file mode 100644 index 0000000000000..25c8dd7621464 --- /dev/null +++ b/pkg/sql/compile/siriusbridge/schema_evidence.go @@ -0,0 +1,65 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package siriusbridge + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + + "github.com/matrixorigin/matrixone/pkg/common/moerr" +) + +// ColumnEvidence contains declared descriptors. Physical decimal width belongs +// to OID; precision alone cannot establish it. Headings participate only in SHA. +type ColumnEvidence struct { + OID int32 `json:"oid"` + Precision int32 `json:"precision"` + Scale int32 `json:"scale"` + Nullable bool `json:"nullable"` +} + +// SchemaEvidence shares the existing embedded digest with the independently +// executed native-MO result owner. It makes no native calls. +func SchemaEvidence(columns []Column) ([]ColumnEvidence, string, error) { + if len(columns) == 0 || len(columns) > maxColumns { + return nil, "", moerr.NewInvalidInputNoCtx("campaign schema column bound exceeded") + } + nameBytes := 0 + for _, column := range columns { + if len(column.Name) > maxMetadataTextBytes-nameBytes || column.OID > 1<<31-1 { + return nil, "", moerr.NewInvalidInputNoCtx("campaign schema text or OID bound exceeded") + } + nameBytes += len(column.Name) + } + result := make([]ColumnEvidence, len(columns)) + digest := sha256.New() + _, _ = digest.Write([]byte("sirius-mo-output-schema-v1\x00")) + var descriptor [17]byte + for index, column := range columns { + result[index] = ColumnEvidence{OID: int32(column.OID), Precision: column.Width, Scale: column.Scale, Nullable: column.Nullable} + binary.LittleEndian.PutUint32(descriptor[:4], column.OID) + binary.LittleEndian.PutUint32(descriptor[4:8], uint32(column.Width)) + binary.LittleEndian.PutUint32(descriptor[8:12], uint32(column.Scale)) + descriptor[12] = 0 + if column.Nullable { + descriptor[12] = 1 + } + binary.LittleEndian.PutUint32(descriptor[13:], uint32(len(column.Name))) + _, _ = digest.Write(descriptor[:]) + _, _ = digest.Write([]byte(column.Name)) + } + return result, hex.EncodeToString(digest.Sum(nil)), nil +} diff --git a/pkg/sql/compile/siriusbridge/schema_evidence_test.go b/pkg/sql/compile/siriusbridge/schema_evidence_test.go new file mode 100644 index 0000000000000..1723b84663ad7 --- /dev/null +++ b/pkg/sql/compile/siriusbridge/schema_evidence_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package siriusbridge + +import ( + "strings" + "testing" + + "github.com/matrixorigin/matrixone/pkg/container/types" + "github.com/stretchr/testify/require" +) + +func TestSchemaEvidenceRetainsPhysicalWidthAndNamesOnlyInDigest(t *testing.T) { + column := Column{OID: uint32(types.T_decimal256), Width: 8, Scale: 2, Nullable: true, Name: "private heading"} + evidence, digest, err := SchemaEvidence([]Column{column}) + require.NoError(t, err) + require.Equal(t, []ColumnEvidence{{OID: int32(types.T_decimal256), Precision: 8, Scale: 2, Nullable: true}}, evidence) + column.OID = uint32(types.T_decimal64) + _, narrowDigest, err := SchemaEvidence([]Column{column}) + require.NoError(t, err) + require.NotEqual(t, digest, narrowDigest) + column.Name = "different heading" + _, renamedDigest, err := SchemaEvidence([]Column{column}) + require.NoError(t, err) + require.NotEqual(t, narrowDigest, renamedDigest) + _, _, err = SchemaEvidence(nil) + require.Error(t, err) + _, _, err = SchemaEvidence(make([]Column, 1025)) + require.Error(t, err) + column.Name = strings.Repeat("x", (1<<20)+1) + _, _, err = SchemaEvidence([]Column{column}) + require.Error(t, err) +} diff --git a/pkg/sql/plan/substrait/embedded_steps.go b/pkg/sql/plan/substrait/embedded_steps.go new file mode 100644 index 0000000000000..e9f8a9328893a --- /dev/null +++ b/pkg/sql/plan/substrait/embedded_steps.go @@ -0,0 +1,109 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package substrait + +import ( + "github.com/matrixorigin/matrixone/pkg/common/moerr" + planpb "github.com/matrixorigin/matrixone/pkg/pb/plan" + planbuilder "github.com/matrixorigin/matrixone/pkg/sql/plan" +) + +// ExpandEmbeddedMOSteps gives every inlined shared-subtree occurrence its own +// scan node. The native importer inlines ReferenceRel; MO inputs are destructive +// streams, so those occurrences cannot share a read binding. The caller first +// validates the original candidate and uses this query for both export and the +// producer specifications. Flight retains its shared-step contract. +func ExpandEmbeddedMOSteps(query *planpb.Query) (*planpb.Query, error) { + if query == nil || len(query.Steps) == 0 { + return nil, moerr.NewInternalErrorNoCtx("substrait: embedded expansion requires a query root") + } + if len(query.Steps) == 1 { + return query, nil + } + const maxExpandedNodes = 4096 + if len(query.Nodes) > maxExpandedNodes { + return nil, notEligiblef(EligibilityPlanShape, "embedded shared-step expansion exceeds node bound") + } + result := planbuilder.DeepCopyQuery(query) + visiting := make(map[int32]bool) + expanded, reads := 0, 0 + var copyOccurrence func(int32, int, int) (int32, error) + copyOccurrence = func(id int32, step, depth int) (int32, error) { + if id < 0 || int(id) >= len(query.Nodes) || query.Nodes[id] == nil || query.Nodes[id].NodeId != id { + return 0, moerr.NewInternalErrorNoCtx("substrait: malformed embedded shared-step node") + } + if visiting[id] { + return 0, moerr.NewInternalErrorNoCtx("substrait: cyclic embedded shared-step query") + } + if expanded >= maxExpandedNodes || depth >= 256 { + return 0, notEligiblef(EligibilityPlanShape, "embedded shared-step expansion exceeds node/depth bound") + } + expanded++ + visiting[id] = true + defer delete(visiting, id) + node := planbuilder.DeepCopyNode(query.Nodes[id]) + projectionType := planpb.Node_PROJECT + if len(node.ProjectList) == 0 { + projectionType = planpb.Node_FILTER // empty project is an identity + } + if node.NodeType == planpb.Node_SINK_SCAN { + if len(node.SourceStep) != 1 || node.SourceStep[0] < 0 || int(node.SourceStep[0]) >= step || len(node.Children) != 0 { + return 0, moerr.NewInternalErrorNoCtx("substrait: malformed embedded shared-step reference") + } + sourceStep := int(node.SourceStep[0]) + producer := query.Steps[sourceStep] + if producer < 0 || int(producer) >= len(query.Nodes) || query.Nodes[producer] == nil || query.Nodes[producer].NodeType != planpb.Node_SINK { + return 0, moerr.NewInternalErrorNoCtx("substrait: embedded shared-step reference requires a sink") + } + child, err := copyOccurrence(producer, sourceStep, depth+1) + if err != nil { + return 0, err + } + if len(node.FilterList) != 0 { + return 0, notEligiblef(EligibilityPlanShape, "embedded sink scan has unsupported filters") + } + node.NodeType, node.SourceStep, node.Children = projectionType, nil, []int32{child} + } else { + for i, child := range node.Children { + copied, err := copyOccurrence(child, step, depth+1) + if err != nil { + return 0, err + } + node.Children[i] = copied + } + if node.NodeType == planpb.Node_SINK { + if len(node.FilterList) != 0 { + return 0, notEligiblef(EligibilityPlanShape, "embedded sink has unsupported filters") + } + node.NodeType = projectionType + } + } + if node.NodeType == planpb.Node_TABLE_SCAN { + reads++ + if reads > 16 { + return 0, notEligiblef(EligibilityPlanShape, "embedded shared-step expansion exceeds sixteen read bindings") + } + } + node.NodeId = int32(len(result.Nodes)) + result.Nodes = append(result.Nodes, node) + return node.NodeId, nil + } + root, err := copyOccurrence(query.Steps[len(query.Steps)-1], len(query.Steps)-1, 0) + if err != nil { + return nil, err + } + result.Steps = []int32{root} + return result, nil +} diff --git a/pkg/sql/plan/substrait/embedded_steps_test.go b/pkg/sql/plan/substrait/embedded_steps_test.go new file mode 100644 index 0000000000000..e82a49235ca73 --- /dev/null +++ b/pkg/sql/plan/substrait/embedded_steps_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Matrix Origin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package substrait + +import ( + "os" + "testing" + + gogoproto "github.com/gogo/protobuf/proto" + planpb "github.com/matrixorigin/matrixone/pkg/pb/plan" + "github.com/stretchr/testify/require" +) + +func TestEmbeddedSharedStepsHaveIndependentReadOccurrences(t *testing.T) { + sql, err := os.ReadFile("../tpch/q15.sql") + require.NoError(t, err) + query := boundSQLQuery(t, string(sql)) + before := gogoproto.Clone(query) + profile := NewEmbeddedExportProfile(31) + original, err := ExportEmbeddedMO(query, profile) + require.NoError(t, err) + originalReads, err := original.EmbeddedMOReads() + require.NoError(t, err) + require.Len(t, originalReads, 2) + expanded, err := ExpandEmbeddedMOSteps(query) + require.NoError(t, err) + require.Len(t, expanded.Steps, 1) + require.True(t, gogoproto.Equal(before, query), "the public/native MO plan remains caller-owned") + candidate, err := ExportEmbeddedMO(expanded, profile) + require.NoError(t, err) + reads, err := candidate.EmbeddedMOReads() + require.NoError(t, err) + require.Len(t, reads, 3, "supplier plus two independently consumed lineitem scans") + bindings := make(map[int32]EmbeddedReadBinding) + lineitem := 0 + for i, read := range reads { + _, duplicate := bindings[read.NodeID] + require.False(t, duplicate) + bindings[read.NodeID] = EmbeddedReadBinding{BindingID: uint64(i + 1), Source: EmbeddedReadMO} + if read.Table == "lineitem" { + lineitem++ + require.Equal(t, originalReads[0].Columns, read.Columns) + } + } + require.Equal(t, 2, lineitem) + wire, err := candidate.BuildEmbedded(bindings) + require.NoError(t, err) + plan := embeddedPlan(t, wire) + require.Len(t, plan.Relations, 1) + require.NotNil(t, plan.Relations[0].GetRoot()) +} + +func sharedStepsQuery(project bool) *planpb.Query { + query := scanQuery() + var projection []*planpb.Expr + if project { + projection = []*planpb.Expr{col(0)} + } + query.Nodes = append(query.Nodes, + &planpb.Node{NodeId: 1, NodeType: planpb.Node_SINK, Children: []int32{0}, ProjectList: projection}, + &planpb.Node{NodeId: 2, NodeType: planpb.Node_SINK_SCAN, SourceStep: []int32{0}, ProjectList: projection}, + &planpb.Node{NodeId: 3, NodeType: planpb.Node_SINK_SCAN, SourceStep: []int32{0}, ProjectList: projection}, + &planpb.Node{NodeId: 4, NodeType: planpb.Node_JOIN, Children: []int32{2, 3}, JoinType: planpb.Node_INNER}, + ) + query.Steps, query.Headings = []int32{1, 4}, []string{"left", "right"} + return query +} + +func TestEmbeddedSharedStepIdentityAndInvalidGraphs(t *testing.T) { + for _, project := range []bool{true, false} { + query := sharedStepsQuery(project) + expanded, err := ExpandEmbeddedMOSteps(query) + require.NoError(t, err) + candidate, err := ExportEmbeddedMO(expanded, NewEmbeddedExportProfile(31)) + require.NoError(t, err) + reads, err := candidate.EmbeddedMOReads() + require.NoError(t, err) + require.Len(t, reads, 2) + require.NotEqual(t, reads[0].NodeID, reads[1].NodeID) + } + for _, mutate := range []func(*planpb.Query){ + func(q *planpb.Query) { q.Nodes[1].Children = []int32{1} }, + func(q *planpb.Query) { q.Nodes[2].SourceStep = []int32{1} }, + func(q *planpb.Query) { q.Nodes[1].NodeType = planpb.Node_PROJECT }, + func(q *planpb.Query) { q.Nodes[2].NodeId = 9 }, + func(q *planpb.Query) { q.Nodes[4].Children[0] = -1 }, + } { + query := sharedStepsQuery(true) + mutate(query) + _, err := ExpandEmbeddedMOSteps(query) + require.Error(t, err) + } + _, err := ExpandEmbeddedMOSteps(nil) + require.Error(t, err) + query := sharedStepsQuery(true) + for len(query.Steps) < 6 { + step := int32(len(query.Steps) - 1) + producer := int32(len(query.Nodes)) + query.Nodes = append(query.Nodes, + &planpb.Node{NodeId: producer, NodeType: planpb.Node_SINK, Children: []int32{query.Steps[step]}}, + &planpb.Node{NodeId: producer + 1, NodeType: planpb.Node_SINK_SCAN, SourceStep: []int32{step}}, + &planpb.Node{NodeId: producer + 2, NodeType: planpb.Node_SINK_SCAN, SourceStep: []int32{step}}, + &planpb.Node{NodeId: producer + 3, NodeType: planpb.Node_JOIN, Children: []int32{producer + 1, producer + 2}}, + ) + query.Steps[step] = producer + query.Steps = append(query.Steps, producer+3) + } + _, err = ExpandEmbeddedMOSteps(query) + require.ErrorContains(t, err, "sixteen read bindings") +} diff --git a/pkg/sql/plan/substrait/numeric_native_integration_test.go b/pkg/sql/plan/substrait/numeric_native_integration_test.go index 918afff5c05a6..944dc82090830 100644 --- a/pkg/sql/plan/substrait/numeric_native_integration_test.go +++ b/pkg/sql/plan/substrait/numeric_native_integration_test.go @@ -50,6 +50,9 @@ func TestExactEmbeddedTPCHNativePreparation(t *testing.T) { for number := 1; number <= 22; number++ { t.Run(fmt.Sprintf("q%d", number), func(t *testing.T) { bound := exactTPCHQuery(t, mock, number) + expanded, err := ExpandEmbeddedMOSteps(bound) + require.NoError(t, err) + bound = expanded candidate, err := ExportEmbeddedMO(bound, NewEmbeddedExportProfile(runtime.Capabilities())) require.NoError(t, err) reads, err := candidate.EmbeddedMOReads() diff --git a/third_party/sirius b/third_party/sirius index 908ffc75a58b3..955b6881afa6e 160000 --- a/third_party/sirius +++ b/third_party/sirius @@ -1 +1 @@ -Subproject commit 908ffc75a58b3be496b2172795e416326416e7ec +Subproject commit 955b6881afa6e92d0995f43e3fe9c383d86981c0