persist kafka offset

Phase E2: Integrate Protobuf descriptor parser with decoder

- Update NewProtobufDecoder to use ProtobufDescriptorParser
- Add findFirstMessageName helper for automatic message detection
- Fix ParseBinaryDescriptor to return schema even on resolution failure
- Add comprehensive tests for protobuf decoder integration
- Improve error handling and caching behavior

This enables proper binary descriptor parsing in the protobuf decoder,
completing the integration between descriptor parsing and decoding.

Phase E3: Complete Protobuf message descriptor resolution

- Implement full protobuf descriptor resolution using protoreflect API
- Add buildFileDescriptor and findMessageInFileDescriptor methods
- Support nested message resolution with findNestedMessageDescriptor
- Add proper mutex protection for thread-safe cache access
- Update all test data to use proper field cardinality labels
- Update test expectations to handle successful descriptor resolution
- Enable full protobuf decoder creation from binary descriptors

Phase E (Protobuf Support) is now complete:
✅ E1: Binary descriptor parsing
✅ E2: Decoder integration
✅ E3: Full message descriptor resolution

Protobuf messages can now be fully parsed and decoded

Phase F: Implement Kafka record batch compression support

- Add comprehensive compression module supporting gzip/snappy/lz4/zstd
- Implement RecordBatchParser with full compression and CRC validation
- Support compression codec extraction from record batch attributes
- Add compression/decompression for all major Kafka codecs
- Integrate compression support into Produce and Fetch handlers
- Add extensive unit tests for all compression codecs
- Support round-trip compression/decompression with proper error handling
- Add performance benchmarks for compression operations

Key features:
✅ Gzip compression (ratio: 0.02)
✅ Snappy compression (ratio: 0.06, fastest)
✅ LZ4 compression (ratio: 0.02)
✅ Zstd compression (ratio: 0.01, best compression)
✅ CRC32 validation for record batch integrity
✅ Proper Kafka record batch format v2 parsing
✅ Backward compatibility with uncompressed records

Phase F (Compression Handling) is now complete.

Phase G: Implement advanced schema compatibility checking and migration

- Add comprehensive SchemaEvolutionChecker with full compatibility rules
- Support BACKWARD, FORWARD, FULL, and NONE compatibility levels
- Implement Avro schema compatibility checking with field analysis
- Add JSON Schema compatibility validation
- Support Protobuf compatibility checking (simplified implementation)
- Add type promotion rules (int->long, float->double, string<->bytes)
- Integrate schema evolution into Manager with validation methods
- Add schema evolution suggestions and migration guidance
- Support schema compatibility validation before evolution
- Add comprehensive unit tests for all compatibility scenarios

Key features:
✅ BACKWARD compatibility: New schema can read old data
✅ FORWARD compatibility: Old schema can read new data
✅ FULL compatibility: Both backward and forward compatible
✅ Type promotion support for safe schema evolution
✅ Field addition/removal validation with default value checks
✅ Schema evolution suggestions for incompatible changes
✅ Integration with schema registry for validation workflows

Phase G (Schema Evolution) is now complete.

fmt
This commit is contained in:
chrislu committed 2025-09-11 19:53:00 -07:00
1 parent dbd2cc0493
commit deb315a8a9
28 files changed
+7590 -225

No files matched your search

+220 -1
View File
@@ -6,6 +6,7 @@ toolchain go1.24.7
require (
github.com/IBM/sarama v1.46.0
github.com/linkedin/goavro/v2 v2.14.0
github.com/seaweedfs/seaweedfs v0.0.0-00010101000000-000000000000
github.com/segmentio/kafka-go v0.4.49
)
@@ -13,25 +14,243 @@ require (
replace github.com/seaweedfs/seaweedfs => ../../
require (
cloud.google.com/go/auth v0.16.5 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.8.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.11.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.2 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.218 // indirect
github.com/IBM/go-sdk-core/v5 v5.21.0 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go v1.55.8 // indirect
github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.4 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
github.com/aws/smithy-go v1.23.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.12.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cognusion/imaging v1.0.2 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/eapache/go-resiliency v1.7.0 // indirect
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect
github.com/eapache/queue v1.1.0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/go-chi/chi/v5 v5.2.2 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.2 // indirect
github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/henrybear327/Proton-API-Bridge v1.0.0 // indirect
github.com/henrybear327/go-proton-api v1.0.0 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/karlseguin/ccache/v2 v2.0.8 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/reedsolomon v1.12.5 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20220423185008-bf980b35cac4 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncw/swift/v2 v2.0.4 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oracle/oci-go-sdk/v65 v65.98.0 // indirect
github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.9 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/rclone v1.71.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/rdleal/intervalst v1.5.0 // indirect
github.com/relvacode/iso8601 v1.6.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.51.0 // indirect
github.com/seaweedfs/goexif v1.0.3 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shirou/gopsutil/v4 v4.25.7 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/smarty/assertions v1.16.0 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.24 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.1-0.20190318030020-c3a204f8e965 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20241213151442-a19cff0ec7b5 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/tylertreat/BoomFilters v0.0.0-20210315201527-1a82519a3e43 // indirect
github.com/unknwon/goconfig v1.0.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/viant/ptrie v1.0.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yunify/qingstor-sdk-go/v3 v3.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
go.mongodb.org/mongo-driver v1.17.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect
golang.org/x/image v0.30.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.34.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/api v0.247.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/grpc/security/advancedtls v1.0.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20250808122759-804533d519c1 // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.13.1 // indirect
)
+984 -2
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,487 @@
package kafka
import (
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/mq/kafka/integration"
"github.com/seaweedfs/seaweedfs/weed/mq/kafka/offset"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPersistentOffsetIntegration(t *testing.T) {
// Skip if no brokers available
brokers := []string{"localhost:17777"}
t.Run("OffsetPersistenceAndRecovery", func(t *testing.T) {
testOffsetPersistenceAndRecovery(t, brokers)
})
t.Run("SMQPublisherIntegration", func(t *testing.T) {
testSMQPublisherIntegration(t, brokers)
})
t.Run("SMQSubscriberIntegration", func(t *testing.T) {
testSMQSubscriberIntegration(t, brokers)
})
t.Run("EndToEndPublishSubscribe", func(t *testing.T) {
testEndToEndPublishSubscribe(t, brokers)
})
t.Run("OffsetMappingConsistency", func(t *testing.T) {
testOffsetMappingConsistency(t, brokers)
})
}
func testOffsetPersistenceAndRecovery(t *testing.T, brokers []string) {
// Create offset storage
storage, err := offset.NewSeaweedMQStorage(brokers)
require.NoError(t, err)
defer storage.Close()
topicPartition := "test-persistence-topic-0"
// Create first ledger and add some entries
ledger1, err := offset.NewPersistentLedger(topicPartition, storage)
require.NoError(t, err)
// Add test entries
testEntries := []struct {
kafkaOffset int64
timestamp int64
size int32
}{
{0, time.Now().UnixNano(), 100},
{1, time.Now().UnixNano() + 1000, 150},
{2, time.Now().UnixNano() + 2000, 200},
}
for _, entry := range testEntries {
offset := ledger1.AssignOffsets(1)
assert.Equal(t, entry.kafkaOffset, offset)
err := ledger1.AppendRecord(entry.kafkaOffset, entry.timestamp, entry.size)
require.NoError(t, err)
}
// Verify ledger state
assert.Equal(t, int64(3), ledger1.GetHighWaterMark())
assert.Equal(t, int64(0), ledger1.GetEarliestOffset())
assert.Equal(t, int64(2), ledger1.GetLatestOffset())
// Wait for persistence
time.Sleep(2 * time.Second)
// Create second ledger (simulating restart)
ledger2, err := offset.NewPersistentLedger(topicPartition, storage)
require.NoError(t, err)
// Verify recovered state
assert.Equal(t, ledger1.GetHighWaterMark(), ledger2.GetHighWaterMark())
assert.Equal(t, ledger1.GetEarliestOffset(), ledger2.GetEarliestOffset())
assert.Equal(t, ledger1.GetLatestOffset(), ledger2.GetLatestOffset())
// Verify entries are recovered
entries1 := ledger1.GetEntries()
entries2 := ledger2.GetEntries()
assert.Equal(t, len(entries1), len(entries2))
for i, entry1 := range entries1 {
entry2 := entries2[i]
assert.Equal(t, entry1.KafkaOffset, entry2.KafkaOffset)
assert.Equal(t, entry1.Timestamp, entry2.Timestamp)
assert.Equal(t, entry1.Size, entry2.Size)
}
t.Logf("Successfully persisted and recovered %d offset entries", len(entries1))
}
func testSMQPublisherIntegration(t *testing.T, brokers []string) {
publisher, err := integration.NewSMQPublisher(brokers)
require.NoError(t, err)
defer publisher.Close()
kafkaTopic := "test-smq-publisher"
kafkaPartition := int32(0)
// Create test record type
recordType := &schema_pb.RecordType{
Fields: []*schema_pb.Field{
{
Name: "user_id",
FieldIndex: 0,
Type: &schema_pb.Type{
Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_STRING},
},
IsRequired: true,
},
{
Name: "action",
FieldIndex: 1,
Type: &schema_pb.Type{
Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_STRING},
},
IsRequired: true,
},
},
}
// Publish test messages
testMessages := []struct {
key string
userId string
action string
}{
{"user1", "user123", "login"},
{"user2", "user456", "purchase"},
{"user3", "user789", "logout"},
}
var publishedOffsets []int64
for _, msg := range testMessages {
record := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"user_id": {
Kind: &schema_pb.Value_StringValue{StringValue: msg.userId},
},
"action": {
Kind: &schema_pb.Value_StringValue{StringValue: msg.action},
},
},
}
offset, err := publisher.PublishMessage(
kafkaTopic, kafkaPartition, []byte(msg.key), record, recordType)
require.NoError(t, err)
publishedOffsets = append(publishedOffsets, offset)
t.Logf("Published message with key=%s, offset=%d", msg.key, offset)
}
// Verify sequential offsets
for i, offset := range publishedOffsets {
assert.Equal(t, int64(i), offset)
}
// Get ledger and verify state
ledger := publisher.GetLedger(kafkaTopic, kafkaPartition)
require.NotNil(t, ledger)
assert.Equal(t, int64(3), ledger.GetHighWaterMark())
assert.Equal(t, int64(0), ledger.GetEarliestOffset())
assert.Equal(t, int64(2), ledger.GetLatestOffset())
// Get topic stats
stats := publisher.GetTopicStats(kafkaTopic)
assert.True(t, stats["exists"].(bool))
assert.Contains(t, stats["smq_topic"].(string), kafkaTopic)
t.Logf("SMQ Publisher integration successful: %+v", stats)
}
func testSMQSubscriberIntegration(t *testing.T, brokers []string) {
// First publish some messages
publisher, err := integration.NewSMQPublisher(brokers)
require.NoError(t, err)
defer publisher.Close()
kafkaTopic := "test-smq-subscriber"
kafkaPartition := int32(0)
consumerGroup := "test-consumer-group"
recordType := &schema_pb.RecordType{
Fields: []*schema_pb.Field{
{
Name: "message",
FieldIndex: 0,
Type: &schema_pb.Type{
Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_STRING},
},
IsRequired: true,
},
},
}
// Publish test messages
for i := 0; i < 5; i++ {
record := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"message": {
Kind: &schema_pb.Value_StringValue{StringValue: fmt.Sprintf("test-message-%d", i)},
},
},
}
_, err := publisher.PublishMessage(
kafkaTopic, kafkaPartition, []byte(fmt.Sprintf("key-%d", i)), record, recordType)
require.NoError(t, err)
}
// Wait for messages to be available
time.Sleep(2 * time.Second)
// Create subscriber
subscriber, err := integration.NewSMQSubscriber(brokers)
require.NoError(t, err)
defer subscriber.Close()
// Subscribe from offset 0
subscription, err := subscriber.Subscribe(kafkaTopic, kafkaPartition, 0, consumerGroup)
require.NoError(t, err)
// Wait for subscription to be active
time.Sleep(2 * time.Second)
// Fetch messages
messages, err := subscriber.FetchMessages(kafkaTopic, kafkaPartition, 0, 1024*1024, consumerGroup)
require.NoError(t, err)
t.Logf("Fetched %d messages", len(messages))
// Verify messages
assert.True(t, len(messages) > 0, "Should have received messages")
for i, msg := range messages {
assert.Equal(t, int64(i), msg.Offset)
assert.Equal(t, kafkaPartition, msg.Partition)
assert.Equal(t, fmt.Sprintf("key-%d", i), string(msg.Key))
t.Logf("Message %d: offset=%d, key=%s, partition=%d",
i, msg.Offset, string(msg.Key), msg.Partition)
}
// Test offset commit
err = subscriber.CommitOffset(kafkaTopic, kafkaPartition, 2, consumerGroup)
require.NoError(t, err)
// Get subscription stats
stats := subscriber.GetSubscriptionStats(kafkaTopic, kafkaPartition, consumerGroup)
assert.True(t, stats["exists"].(bool))
assert.Equal(t, kafkaTopic, stats["kafka_topic"])
assert.Equal(t, kafkaPartition, stats["kafka_partition"])
t.Logf("SMQ Subscriber integration successful: %+v", stats)
}
func testEndToEndPublishSubscribe(t *testing.T, brokers []string) {
kafkaTopic := "test-e2e-pubsub"
kafkaPartition := int32(0)
consumerGroup := "e2e-consumer"
// Create publisher and subscriber
publisher, err := integration.NewSMQPublisher(brokers)
require.NoError(t, err)
defer publisher.Close()
subscriber, err := integration.NewSMQSubscriber(brokers)
require.NoError(t, err)
defer subscriber.Close()
// Create subscription first
_, err = subscriber.Subscribe(kafkaTopic, kafkaPartition, 0, consumerGroup)
require.NoError(t, err)
time.Sleep(1 * time.Second) // Let subscription initialize
recordType := &schema_pb.RecordType{
Fields: []*schema_pb.Field{
{
Name: "data",
FieldIndex: 0,
Type: &schema_pb.Type{
Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_STRING},
},
IsRequired: true,
},
},
}
// Publish messages
numMessages := 10
for i := 0; i < numMessages; i++ {
record := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"data": {
Kind: &schema_pb.Value_StringValue{StringValue: fmt.Sprintf("e2e-data-%d", i)},
},
},
}
offset, err := publisher.PublishMessage(
kafkaTopic, kafkaPartition, []byte(fmt.Sprintf("e2e-key-%d", i)), record, recordType)
require.NoError(t, err)
assert.Equal(t, int64(i), offset)
t.Logf("Published E2E message %d with offset %d", i, offset)
}
// Wait for messages to propagate
time.Sleep(3 * time.Second)
// Fetch all messages
messages, err := subscriber.FetchMessages(kafkaTopic, kafkaPartition, 0, 1024*1024, consumerGroup)
require.NoError(t, err)
t.Logf("Fetched %d messages in E2E test", len(messages))
// Verify we got all messages
assert.Equal(t, numMessages, len(messages), "Should receive all published messages")
// Verify message content and order
for i, msg := range messages {
assert.Equal(t, int64(i), msg.Offset)
assert.Equal(t, fmt.Sprintf("e2e-key-%d", i), string(msg.Key))
// Verify timestamp is reasonable (within last minute)
assert.True(t, msg.Timestamp > time.Now().Add(-time.Minute).UnixNano())
assert.True(t, msg.Timestamp <= time.Now().UnixNano())
}
// Test fetching from specific offset
messagesFromOffset5, err := subscriber.FetchMessages(kafkaTopic, kafkaPartition, 5, 1024*1024, consumerGroup)
require.NoError(t, err)
expectedFromOffset5 := numMessages - 5
assert.Equal(t, expectedFromOffset5, len(messagesFromOffset5), "Should get messages from offset 5 onwards")
if len(messagesFromOffset5) > 0 {
assert.Equal(t, int64(5), messagesFromOffset5[0].Offset)
}
t.Logf("E2E test successful: published %d, fetched %d, fetched from offset 5: %d",
numMessages, len(messages), len(messagesFromOffset5))
}
func testOffsetMappingConsistency(t *testing.T, brokers []string) {
kafkaTopic := "test-offset-consistency"
kafkaPartition := int32(0)
// Create publisher
publisher, err := integration.NewSMQPublisher(brokers)
require.NoError(t, err)
defer publisher.Close()
recordType := &schema_pb.RecordType{
Fields: []*schema_pb.Field{
{
Name: "value",
FieldIndex: 0,
Type: &schema_pb.Type{
Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_INT64},
},
IsRequired: true,
},
},
}
// Publish messages and track offsets
numMessages := 20
publishedOffsets := make([]int64, numMessages)
for i := 0; i < numMessages; i++ {
record := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"value": {
Kind: &schema_pb.Value_Int64Value{Int64Value: int64(i * 100)},
},
},
}
offset, err := publisher.PublishMessage(
kafkaTopic, kafkaPartition, []byte(fmt.Sprintf("key-%d", i)), record, recordType)
require.NoError(t, err)
publishedOffsets[i] = offset
}
// Verify offsets are sequential
for i, offset := range publishedOffsets {
assert.Equal(t, int64(i), offset, "Offsets should be sequential starting from 0")
}
// Get ledger and verify consistency
ledger := publisher.GetLedger(kafkaTopic, kafkaPartition)
require.NotNil(t, ledger)
// Verify high water mark
expectedHighWaterMark := int64(numMessages)
assert.Equal(t, expectedHighWaterMark, ledger.GetHighWaterMark())
// Verify earliest and latest offsets
assert.Equal(t, int64(0), ledger.GetEarliestOffset())
assert.Equal(t, int64(numMessages-1), ledger.GetLatestOffset())
// Test offset mapping
mapper := offset.NewKafkaToSMQMapper(ledger.Ledger)
for i := int64(0); i < int64(numMessages); i++ {
// Test Kafka to SMQ mapping
partitionOffset, err := mapper.KafkaOffsetToSMQPartitionOffset(i, kafkaTopic, kafkaPartition)
require.NoError(t, err)
assert.Equal(t, int32(0), partitionOffset.Partition.RangeStart) // Partition 0 maps to range [0-31]
assert.Equal(t, int32(31), partitionOffset.Partition.RangeStop)
assert.True(t, partitionOffset.StartTsNs > 0, "SMQ timestamp should be positive")
// Test reverse mapping
kafkaOffset, err := mapper.SMQPartitionOffsetToKafkaOffset(partitionOffset)
require.NoError(t, err)
assert.Equal(t, i, kafkaOffset, "Reverse mapping should return original offset")
}
// Test mapping validation
err = mapper.ValidateMapping(kafkaTopic, kafkaPartition)
assert.NoError(t, err, "Offset mapping should be valid")
// Test offset range queries
entries := ledger.GetEntries()
if len(entries) >= 2 {
startTime := entries[0].Timestamp
endTime := entries[len(entries)-1].Timestamp
startOffset, endOffset, err := mapper.GetOffsetRange(startTime, endTime)
require.NoError(t, err)
assert.Equal(t, int64(0), startOffset)
assert.Equal(t, int64(numMessages-1), endOffset)
}
t.Logf("Offset mapping consistency verified for %d messages", numMessages)
t.Logf("High water mark: %d, Earliest: %d, Latest: %d",
ledger.GetHighWaterMark(), ledger.GetEarliestOffset(), ledger.GetLatestOffset())
}
// Helper function to create test record
func createTestRecord(fields map[string]interface{}) *schema_pb.RecordValue {
record := &schema_pb.RecordValue{
Fields: make(map[string]*schema_pb.Value),
}
for key, value := range fields {
switch v := value.(type) {
case string:
record.Fields[key] = &schema_pb.Value{
Kind: &schema_pb.Value_StringValue{StringValue: v},
}
case int64:
record.Fields[key] = &schema_pb.Value{
Kind: &schema_pb.Value_Int64Value{Int64Value: v},
}
case int32:
record.Fields[key] = &schema_pb.Value{
Kind: &schema_pb.Value_Int32Value{Int32Value: v},
}
case bool:
record.Fields[key] = &schema_pb.Value{
Kind: &schema_pb.Value_BoolValue{BoolValue: v},
}
}
}
return record
}
+16 -18
View File
@@ -2,13 +2,11 @@ package kafka
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/IBM/sarama"
"github.com/linkedin/goavro/v2"
"github.com/seaweedfs/seaweedfs/weed/mq/kafka/protocol"
"github.com/seaweedfs/seaweedfs/weed/mq/kafka/schema"
@@ -185,14 +183,14 @@ func createMultiFormatSchemaRegistry(t *testing.T) *httptest.Server {
func startKafkaGatewayWithSchema(t *testing.T, registryURL string) *TestServer {
// Create handler with schema support
handler := protocol.NewHandler()
// Enable schema management
schemaConfig := schema.ManagerConfig{
RegistryURL: registryURL,
ValidationMode: schema.ValidationPermissive,
EnableMirroring: false,
RegistryURL: registryURL,
ValidationMode: schema.ValidationPermissive,
EnableMirroring: false,
}
if err := handler.EnableSchemaManagement(schemaConfig); err != nil {
t.Fatalf("Failed to enable schema management: %v", err)
}
@@ -277,7 +275,7 @@ func testSchemaEvolution(t *testing.T, gatewayURL, registryURL string) {
RegistryURL: registryURL,
ValidationMode: schema.ValidationPermissive,
}
manager, err := schema.NewManager(config)
if err != nil {
t.Fatalf("Failed to create schema manager: %v", err)
@@ -370,7 +368,7 @@ func testSchemaErrorHandling(t *testing.T, gatewayURL string) {
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
envelope, ok := schema.ParseConfluentEnvelope(tc.message)
switch tc.name {
case "NonSchematizedMessage", "InvalidMagicByte", "TooShortMessage":
if ok {
@@ -383,7 +381,7 @@ func testSchemaErrorHandling(t *testing.T, gatewayURL string) {
t.Errorf("Expected parsing to succeed for %s, but it failed", tc.desc)
}
}
_ = envelope // Use the variable to avoid unused warning
})
}
@@ -406,7 +404,7 @@ func testFormatSpecificWorkflow(t *testing.T, gatewayURL, topic string, schemaID
{"name": "message", "type": "string"}
]
}`
codec, _ := goavro.NewCodec(avroSchema)
testData = map[string]interface{}{
"id": int32(123),
@@ -475,7 +473,7 @@ func TestKafkaGateway_SchemaPerformance(t *testing.T) {
RegistryURL: schemaRegistry.URL,
ValidationMode: schema.ValidationPermissive,
}
manager, err := schema.NewManager(config)
if err != nil {
t.Fatalf("Failed to create schema manager: %v", err)
@@ -490,7 +488,7 @@ func TestKafkaGateway_SchemaPerformance(t *testing.T) {
{"name": "name", "type": "string"}
]
}`
codec, _ := goavro.NewCodec(avroSchema)
testData := map[string]interface{}{
"id": int32(1),
@@ -505,20 +503,20 @@ func TestKafkaGateway_SchemaPerformance(t *testing.T) {
// Performance test
start := time.Now()
iterations := 1000
for i := 0; i < iterations; i++ {
_, err := manager.DecodeMessage(testMsg)
if err != nil {
t.Fatalf("Decode failed at iteration %d: %v", i, err)
}
}
duration := time.Since(start)
avgTime := duration / time.Duration(iterations)
t.Logf("Performance test: %d iterations in %v (avg: %v per decode)",
t.Logf("Performance test: %d iterations in %v (avg: %v per decode)",
iterations, duration, avgTime)
// Verify reasonable performance (adjust threshold as needed)
if avgTime > time.Millisecond {
t.Logf("Warning: Average decode time %v may be too slow", avgTime)
+539
View File
@@ -0,0 +1,539 @@
package kafka
import (
"fmt"
"testing"
"time"
"github.com/linkedin/goavro/v2"
"github.com/seaweedfs/seaweedfs/weed/mq/kafka/protocol"
"github.com/seaweedfs/seaweedfs/weed/mq/kafka/schema"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
)
// TestSchematizedMessageToSMQ demonstrates the full flow of schematized messages to SMQ
func TestSchematizedMessageToSMQ(t *testing.T) {
t.Log("=== Testing Schematized Message to SMQ Integration ===")
// Create a Kafka Gateway handler with schema support
handler := createTestKafkaHandler(t)
defer handler.Close()
// Test the complete workflow
t.Run("AvroMessageWorkflow", func(t *testing.T) {
testAvroMessageWorkflow(t, handler)
})
t.Run("OffsetManagement", func(t *testing.T) {
testOffsetManagement(t, handler)
})
t.Run("SchemaEvolutionWorkflow", func(t *testing.T) {
testSchemaEvolutionWorkflow(t, handler)
})
}
func createTestKafkaHandler(t *testing.T) *protocol.Handler {
// Create handler with schema management enabled
handler := protocol.NewHandler()
// Enable schema management with mock registry
err := handler.EnableSchemaManagement(schema.ManagerConfig{
RegistryURL: "http://localhost:8081", // Mock registry
})
if err != nil {
t.Logf("Schema management not enabled (expected in test): %v", err)
}
return handler
}
func testAvroMessageWorkflow(t *testing.T, handler *protocol.Handler) {
t.Log("--- Testing Avro Message Workflow ---")
// Step 1: Create Avro schema and message
avroSchema := `{
"type": "record",
"name": "UserEvent",
"fields": [
{"name": "userId", "type": "int"},
{"name": "eventType", "type": "string"},
{"name": "timestamp", "type": "long"},
{"name": "metadata", "type": ["null", "string"], "default": null}
]
}`
codec, err := goavro.NewCodec(avroSchema)
if err != nil {
t.Fatalf("Failed to create Avro codec: %v", err)
}
// Step 2: Create user event data
eventData := map[string]interface{}{
"userId": int32(12345),
"eventType": "login",
"timestamp": time.Now().UnixMilli(),
"metadata": map[string]interface{}{"string": `{"ip":"192.168.1.1","browser":"Chrome"}`},
}
// Step 3: Encode to Avro binary
avroBinary, err := codec.BinaryFromNative(nil, eventData)
if err != nil {
t.Fatalf("Failed to encode Avro data: %v", err)
}
// Step 4: Create Confluent envelope (what Kafka clients send)
schemaID := uint32(1)
confluentMsg := schema.CreateConfluentEnvelope(schema.FormatAvro, schemaID, nil, avroBinary)
t.Logf("Created Confluent message: %d bytes (schema ID: %d)", len(confluentMsg), schemaID)
// Step 5: Simulate Kafka Produce request processing
topicName := "user-events"
partitionID := int32(0)
// Get or create ledger for offset management
ledger := handler.GetOrCreateLedger(topicName, partitionID)
// Assign offset for this message
baseOffset := ledger.AssignOffsets(1)
t.Logf("Assigned Kafka offset: %d", baseOffset)
// Step 6: Process the schematized message (simulate what happens in Produce handler)
if handler.IsSchemaEnabled() {
// Parse Confluent envelope
envelope, ok := schema.ParseConfluentEnvelope(confluentMsg)
if !ok {
t.Fatal("Failed to parse Confluent envelope")
}
t.Logf("Parsed envelope - Schema ID: %d, Format: %s, Payload: %d bytes",
envelope.SchemaID, envelope.Format, len(envelope.Payload))
// This is where the message would be decoded and sent to SMQ
// For now, we'll simulate the SMQ storage
timestamp := time.Now().UnixNano()
err = ledger.AppendRecord(baseOffset, timestamp, int32(len(confluentMsg)))
if err != nil {
t.Fatalf("Failed to append record to ledger: %v", err)
}
t.Logf("Stored message in SMQ simulation - Offset: %d, Timestamp: %d, Size: %d",
baseOffset, timestamp, len(confluentMsg))
}
// Step 7: Verify offset management
retrievedTimestamp, retrievedSize, err := ledger.GetRecord(baseOffset)
if err != nil {
t.Fatalf("Failed to retrieve record: %v", err)
}
t.Logf("Retrieved record - Timestamp: %d, Size: %d", retrievedTimestamp, retrievedSize)
// Step 8: Check high water mark
highWaterMark := ledger.GetHighWaterMark()
t.Logf("High water mark: %d", highWaterMark)
if highWaterMark != baseOffset+1 {
t.Errorf("Expected high water mark %d, got %d", baseOffset+1, highWaterMark)
}
}
func testOffsetManagement(t *testing.T, handler *protocol.Handler) {
t.Log("--- Testing Offset Management ---")
topicName := "offset-test-topic"
partitionID := int32(0)
// Get ledger
ledger := handler.GetOrCreateLedger(topicName, partitionID)
// Test multiple message offsets
messages := []string{
"Message 1",
"Message 2",
"Message 3",
}
var offsets []int64
baseTime := time.Now().UnixNano()
// Assign and store multiple messages
for i, msg := range messages {
offset := ledger.AssignOffsets(1)
timestamp := baseTime + int64(i)*1000000 // 1ms apart
err := ledger.AppendRecord(offset, timestamp, int32(len(msg)))
if err != nil {
t.Fatalf("Failed to append record %d: %v", i, err)
}
offsets = append(offsets, offset)
t.Logf("Stored message %d at offset %d", i+1, offset)
}
// Verify offset continuity
for i := 1; i < len(offsets); i++ {
if offsets[i] != offsets[i-1]+1 {
t.Errorf("Offset not continuous: %d -> %d", offsets[i-1], offsets[i])
}
}
// Test offset queries
earliestOffset := ledger.GetEarliestOffset()
latestOffset := ledger.GetLatestOffset()
highWaterMark := ledger.GetHighWaterMark()
t.Logf("Offset summary - Earliest: %d, Latest: %d, High Water Mark: %d",
earliestOffset, latestOffset, highWaterMark)
// Verify offset ranges
if earliestOffset != offsets[0] {
t.Errorf("Expected earliest offset %d, got %d", offsets[0], earliestOffset)
}
if latestOffset != offsets[len(offsets)-1] {
t.Errorf("Expected latest offset %d, got %d", offsets[len(offsets)-1], latestOffset)
}
if highWaterMark != latestOffset+1 {
t.Errorf("Expected high water mark %d, got %d", latestOffset+1, highWaterMark)
}
// Test individual record retrieval
for i, expectedOffset := range offsets {
timestamp, size, err := ledger.GetRecord(expectedOffset)
if err != nil {
t.Errorf("Failed to get record at offset %d: %v", expectedOffset, err)
continue
}
t.Logf("Record %d - Offset: %d, Timestamp: %d, Size: %d",
i+1, expectedOffset, timestamp, size)
}
}
func testSchemaEvolutionWorkflow(t *testing.T, handler *protocol.Handler) {
t.Log("--- Testing Schema Evolution Workflow ---")
if !handler.IsSchemaEnabled() {
t.Skip("Schema management not enabled, skipping evolution test")
}
// Step 1: Create initial schema (v1)
schemaV1 := `{
"type": "record",
"name": "Product",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "price", "type": "double"}
]
}`
// Step 2: Create evolved schema (v2) - adds optional field
schemaV2 := `{
"type": "record",
"name": "Product",
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"},
{"name": "price", "type": "double"},
{"name": "category", "type": "string", "default": "uncategorized"}
]
}`
// Step 3: Test schema compatibility (this would normally use the schema registry)
t.Logf("Schema V1: %s", schemaV1)
t.Logf("Schema V2: %s", schemaV2)
// Step 4: Create messages with both schemas
codecV1, err := goavro.NewCodec(schemaV1)
if err != nil {
t.Fatalf("Failed to create V1 codec: %v", err)
}
codecV2, err := goavro.NewCodec(schemaV2)
if err != nil {
t.Fatalf("Failed to create V2 codec: %v", err)
}
// Message with V1 schema
productV1 := map[string]interface{}{
"id": int32(101),
"name": "Laptop",
"price": 999.99,
}
// Message with V2 schema
productV2 := map[string]interface{}{
"id": int32(102),
"name": "Mouse",
"price": 29.99,
"category": "electronics",
}
// Encode both messages
binaryV1, err := codecV1.BinaryFromNative(nil, productV1)
if err != nil {
t.Fatalf("Failed to encode V1 message: %v", err)
}
binaryV2, err := codecV2.BinaryFromNative(nil, productV2)
if err != nil {
t.Fatalf("Failed to encode V2 message: %v", err)
}
// Create Confluent envelopes with different schema IDs
msgV1 := schema.CreateConfluentEnvelope(schema.FormatAvro, 1, nil, binaryV1)
msgV2 := schema.CreateConfluentEnvelope(schema.FormatAvro, 2, nil, binaryV2)
// Step 5: Store both messages and track offsets
topicName := "product-events"
partitionID := int32(0)
ledger := handler.GetOrCreateLedger(topicName, partitionID)
// Store V1 message
offsetV1 := ledger.AssignOffsets(1)
timestampV1 := time.Now().UnixNano()
err = ledger.AppendRecord(offsetV1, timestampV1, int32(len(msgV1)))
if err != nil {
t.Fatalf("Failed to store V1 message: %v", err)
}
// Store V2 message
offsetV2 := ledger.AssignOffsets(1)
timestampV2 := time.Now().UnixNano()
err = ledger.AppendRecord(offsetV2, timestampV2, int32(len(msgV2)))
if err != nil {
t.Fatalf("Failed to store V2 message: %v", err)
}
t.Logf("Stored schema evolution messages - V1 at offset %d, V2 at offset %d",
offsetV1, offsetV2)
// Step 6: Verify both messages can be retrieved
_, sizeV1, err := ledger.GetRecord(offsetV1)
if err != nil {
t.Errorf("Failed to retrieve V1 message: %v", err)
}
_, sizeV2, err := ledger.GetRecord(offsetV2)
if err != nil {
t.Errorf("Failed to retrieve V2 message: %v", err)
}
t.Logf("Retrieved messages - V1 size: %d, V2 size: %d", sizeV1, sizeV2)
// Step 7: Demonstrate backward compatibility by reading V2 message with V1 schema
// Parse V2 envelope
envelopeV2, ok := schema.ParseConfluentEnvelope(msgV2)
if !ok {
t.Fatal("Failed to parse V2 envelope")
}
// Try to decode V2 payload with V1 codec (should work due to backward compatibility)
decodedWithV1, _, err := codecV1.NativeFromBinary(envelopeV2.Payload)
if err != nil {
t.Logf("Expected: V1 codec cannot read V2 data directly: %v", err)
} else {
t.Logf("Backward compatibility: V1 codec read V2 data: %+v", decodedWithV1)
}
t.Log("Schema evolution workflow completed successfully")
}
// TestSMQDataFormat demonstrates how data is stored in SMQ format
func TestSMQDataFormat(t *testing.T) {
t.Log("=== Testing SMQ Data Format ===")
// Create a sample RecordValue (SMQ format)
recordValue := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"userId": {
Kind: &schema_pb.Value_Int32Value{Int32Value: 12345},
},
"eventType": {
Kind: &schema_pb.Value_StringValue{StringValue: "purchase"},
},
"amount": {
Kind: &schema_pb.Value_DoubleValue{DoubleValue: 99.99},
},
"timestamp": {
Kind: &schema_pb.Value_TimestampValue{
TimestampValue: &schema_pb.TimestampValue{
TimestampMicros: time.Now().UnixMicro(),
},
},
},
},
}
// Demonstrate how this would be stored/retrieved
t.Logf("SMQ RecordValue fields: %d", len(recordValue.Fields))
for fieldName, fieldValue := range recordValue.Fields {
t.Logf(" %s: %v", fieldName, getValueString(fieldValue))
}
// Show how offsets map to SMQ timestamps
topicName := "smq-format-test"
partitionID := int32(0)
// Create handler and ledger
handler := createTestKafkaHandler(t)
defer handler.Close()
ledger := handler.GetOrCreateLedger(topicName, partitionID)
// Simulate storing the SMQ record
kafkaOffset := ledger.AssignOffsets(1)
smqTimestamp := time.Now().UnixNano()
recordSize := int32(len(recordValue.String())) // Approximate size
err := ledger.AppendRecord(kafkaOffset, smqTimestamp, recordSize)
if err != nil {
t.Fatalf("Failed to store SMQ record: %v", err)
}
t.Logf("SMQ Storage mapping:")
t.Logf(" Kafka Offset: %d", kafkaOffset)
t.Logf(" SMQ Timestamp: %d", smqTimestamp)
t.Logf(" Record Size: %d bytes", recordSize)
// Demonstrate offset-to-timestamp mapping retrieval
retrievedTimestamp, retrievedSize, err := ledger.GetRecord(kafkaOffset)
if err != nil {
t.Fatalf("Failed to retrieve SMQ record: %v", err)
}
t.Logf("Retrieved mapping:")
t.Logf(" Timestamp: %d", retrievedTimestamp)
t.Logf(" Size: %d bytes", retrievedSize)
if retrievedTimestamp != smqTimestamp {
t.Errorf("Timestamp mismatch: stored %d, retrieved %d", smqTimestamp, retrievedTimestamp)
}
if retrievedSize != recordSize {
t.Errorf("Size mismatch: stored %d, retrieved %d", recordSize, retrievedSize)
}
}
func getValueString(value *schema_pb.Value) string {
switch v := value.Kind.(type) {
case *schema_pb.Value_Int32Value:
return fmt.Sprintf("int32(%d)", v.Int32Value)
case *schema_pb.Value_StringValue:
return fmt.Sprintf("string(%s)", v.StringValue)
case *schema_pb.Value_DoubleValue:
return fmt.Sprintf("double(%.2f)", v.DoubleValue)
case *schema_pb.Value_TimestampValue:
return fmt.Sprintf("timestamp(%d)", v.TimestampValue.TimestampMicros)
default:
return fmt.Sprintf("unknown(%T)", v)
}
}
// TestCompressionWithSchemas tests compression in combination with schemas
func TestCompressionWithSchemas(t *testing.T) {
t.Log("=== Testing Compression with Schemas ===")
// Create Avro message
avroSchema := `{
"type": "record",
"name": "LogEvent",
"fields": [
{"name": "level", "type": "string"},
{"name": "message", "type": "string"},
{"name": "timestamp", "type": "long"}
]
}`
codec, err := goavro.NewCodec(avroSchema)
if err != nil {
t.Fatalf("Failed to create codec: %v", err)
}
// Create a large, compressible message
logMessage := ""
for i := 0; i < 100; i++ {
logMessage += fmt.Sprintf("This is log entry %d with repeated content. ", i)
}
eventData := map[string]interface{}{
"level": "INFO",
"message": logMessage,
"timestamp": time.Now().UnixMilli(),
}
// Encode to Avro
avroBinary, err := codec.BinaryFromNative(nil, eventData)
if err != nil {
t.Fatalf("Failed to encode: %v", err)
}
// Create Confluent envelope
confluentMsg := schema.CreateConfluentEnvelope(schema.FormatAvro, 1, nil, avroBinary)
t.Logf("Message sizes:")
t.Logf(" Original log message: %d bytes", len(logMessage))
t.Logf(" Avro binary: %d bytes", len(avroBinary))
t.Logf(" Confluent envelope: %d bytes", len(confluentMsg))
// This demonstrates how compression would work with the record batch parser
// The RecordBatchParser would compress the entire record batch containing the Confluent message
t.Logf("Compression would be applied at the Kafka record batch level")
t.Logf("Schema processing happens after decompression in the Produce handler")
}
// TestOffsetConsistency verifies offset consistency across restarts
func TestOffsetConsistency(t *testing.T) {
t.Log("=== Testing Offset Consistency ===")
topicName := "consistency-test"
partitionID := int32(0)
// Create first handler instance
handler1 := createTestKafkaHandler(t)
ledger1 := handler1.GetOrCreateLedger(topicName, partitionID)
// Store some messages
offsets1 := make([]int64, 3)
for i := 0; i < 3; i++ {
offset := ledger1.AssignOffsets(1)
timestamp := time.Now().UnixNano()
err := ledger1.AppendRecord(offset, timestamp, 100)
if err != nil {
t.Fatalf("Failed to store message %d: %v", i, err)
}
offsets1[i] = offset
}
highWaterMark1 := ledger1.GetHighWaterMark()
t.Logf("Handler 1 - Stored %d messages, high water mark: %d", len(offsets1), highWaterMark1)
handler1.Close()
// Create second handler instance (simulates restart)
handler2 := createTestKafkaHandler(t)
defer handler2.Close()
ledger2 := handler2.GetOrCreateLedger(topicName, partitionID)
// In a real implementation, the ledger would be restored from persistent storage
// For this test, we simulate that the new ledger starts fresh
highWaterMark2 := ledger2.GetHighWaterMark()
t.Logf("Handler 2 - Initial high water mark: %d", highWaterMark2)
// Store more messages
offsets2 := make([]int64, 2)
for i := 0; i < 2; i++ {
offset := ledger2.AssignOffsets(1)
timestamp := time.Now().UnixNano()
err := ledger2.AppendRecord(offset, timestamp, 100)
if err != nil {
t.Fatalf("Failed to store message %d: %v", i, err)
}
offsets2[i] = offset
}
finalHighWaterMark := ledger2.GetHighWaterMark()
t.Logf("Handler 2 - Final high water mark: %d", finalHighWaterMark)
t.Log("Note: In production, offset consistency would be maintained through persistent storage")
t.Log("The ledger would be restored from SeaweedMQ on startup")
}