Skip to content

Commit 1d6fac1

Browse files
authored
Add data_index_key (#53)
We wanted to store the data from large documents (per some configurable threshold) in separate S3 objects so that they can be downloaded easily through presigned S3 links and not balloon the size of Parquet files. Since DIMO-Network/dis#254 we've been storing these large events as standalone JSON objects, including the headers. But clients do not enjoy unwrapping the data inside the event and parsing the base64. To accommodate this, we introduce a new ClickHouse column `data_index_key`. Unlike the existing `index_key`, this column is also written to the Parquet files (morally, we want to be able to reconstruct a working system from S3 alone). In Go we represent this in a new `StoredEvent` type, rather than adding to `CloudEventHeader`, for two reasons: 1. Security. With this choice it's easier to ensure that client input is never placed into this field. If we failed at this, a client might be able to gain access to files that he should not be able to see. 2. Separation. Many users of the cloudevent package don't care about ClickHouse storage at all. The struct currently doesn't bother them with an `IndexKey` field, and it would be nice to maintain this separation. Old clients that assume the old table structures should still work, but they should upgrade soon.
1 parent 36983ae commit 1d6fac1

10 files changed

Lines changed: 221 additions & 28 deletions

File tree

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,14 @@ A CloudEvent is uniquely identified by the combination of the following fields:
110110
- `Source`
111111
- `ID`
112112

113-
This combination forms the "index key" for the event and is used for deduplication and retrieval purposes.
113+
This combination is the event's logical key (see `CloudEventHeader.Key()`) and is used for deduplication.
114+
115+
### Storage keys
116+
117+
These are storage pointers, set server-side by the ingestion pipeline. They are **not** part of the CloudEvent wire format and must never be set from producer-supplied input.
118+
119+
- `index_key`: pointer to the backing object holding this event's row. For events grouped into a Parquet bundle, the format is `<bundleObjectKey>#<rowOffset>` (see `parquet.ParseIndexKey`). For singleton events, it's the standalone object's key (see `clickhouse.CloudEventToObjectKey`).
120+
- `data_index_key`: pointer to the external object holding this event's payload, when the payload is stored out-of-band (e.g. a large image). Empty when the payload is inline in the row's `data` / `data_base64`. Carried on `cloudevent.StoredEvent.DataIndexKey` at write time and stored as a column in both ClickHouse and the Parquet bundle.
114121

115122
### DIMO Event Types
116123

clickhouse/clickhouse.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ const (
3333
ExtrasColumn = "extras"
3434
// IndexKeyColumn is the name of the index name column in Clickhouse.
3535
IndexKeyColumn = "index_key"
36+
// DataIndexKeyColumn is the name of the data index name column in Clickhouse.
37+
DataIndexKeyColumn = "data_index_key"
3638

3739
// InsertStmt is the SQL statement for inserting a row into Clickhouse.
3840
InsertStmt = "INSERT INTO " + TableName + " (" +
@@ -45,22 +47,37 @@ const (
4547
DataContentTypeColumn + ", " +
4648
DataVersionColumn + ", " +
4749
ExtrasColumn + ", " +
48-
IndexKeyColumn +
49-
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
50+
IndexKeyColumn + ", " +
51+
DataIndexKeyColumn +
52+
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
5053

5154
// hexChars contains the characters used for hex representation
5255
hexChars = "0123456789abcdef"
5356
)
5457

5558
// CloudEventToSlice converts a CloudEvent to an array of any for Clickhouse insertion.
5659
// The order of the elements in the array match the order of the columns in the table.
60+
// index_key is computed from the event's headers.
5761
func CloudEventToSlice(event *cloudevent.CloudEventHeader) []any {
5862
return CloudEventToSliceWithKey(event, CloudEventToObjectKey(event))
5963
}
6064

6165
// CloudEventToSliceWithKey converts a CloudEvent to an array of any for Clickhouse insertion.
6266
// The order of the elements in the array match the order of the columns in the table.
67+
// This variant allows the caller to specify a value for index_key.
6368
func CloudEventToSliceWithKey(event *cloudevent.CloudEventHeader, key string) []any {
69+
return cloudEventToSlice(event, key, "")
70+
}
71+
72+
// StoredEventToSlice converts a StoredEvent to an array of any for Clickhouse
73+
// insertion, populating both index_key (caller-supplied) and data_index_key
74+
// (carried on the wrapper). The order of the elements matches the column
75+
// order in the table.
76+
func StoredEventToSlice(stored *cloudevent.StoredEvent, indexKey string) []any {
77+
return cloudEventToSlice(&stored.CloudEventHeader, indexKey, stored.DataIndexKey)
78+
}
79+
80+
func cloudEventToSlice(event *cloudevent.CloudEventHeader, indexKey, dataIndexKey string) []any {
6481
// Add non-column fields to extras
6582
extras := cloudevent.AddNonColumnFieldsToExtras(event)
6683

@@ -80,7 +97,8 @@ func CloudEventToSliceWithKey(event *cloudevent.CloudEventHeader, key string) []
8097
event.DataContentType,
8198
event.DataVersion,
8299
string(jsonExtra),
83-
key,
100+
indexKey,
101+
dataIndexKey,
84102
}
85103
}
86104

@@ -90,11 +108,11 @@ func UnmarshalCloudEventSlice(jsonArray []byte) ([]any, error) {
90108
if err := json.Unmarshal(jsonArray, &rawSlice); err != nil {
91109
return nil, fmt.Errorf("failed to unmarshal cloud event slice: %w", err)
92110
}
93-
if len(rawSlice) != 10 {
111+
if len(rawSlice) != 11 {
94112
return nil, fmt.Errorf("invalid cloud event slice length: %d", len(rawSlice))
95113
}
96114

97-
// Column order: subject, timestamp, eventType, id, source, producer, dataContentType, dataVersion, extras, indexKey
115+
// Column order: subject, timestamp, eventType, id, source, producer, dataContentType, dataVersion, extras, indexKey, dataIndexKey
98116
var (
99117
subject string
100118
timestamp time.Time
@@ -106,6 +124,7 @@ func UnmarshalCloudEventSlice(jsonArray []byte) ([]any, error) {
106124
dataVersion string
107125
extras string
108126
indexKey string
127+
dataIndexKey string
109128
)
110129
unmarshal := func(i int, name string, ptr any) error {
111130
if err := json.Unmarshal(rawSlice[i], ptr); err != nil {
@@ -143,7 +162,10 @@ func UnmarshalCloudEventSlice(jsonArray []byte) ([]any, error) {
143162
if err := unmarshal(9, "index key", &indexKey); err != nil {
144163
return nil, err
145164
}
146-
return []any{subject, timestamp, eventType, id, source, producer, dataContentType, dataVersion, extras, indexKey}, nil
165+
if err := unmarshal(10, "data index key", &dataIndexKey); err != nil {
166+
return nil, err
167+
}
168+
return []any{subject, timestamp, eventType, id, source, producer, dataContentType, dataVersion, extras, indexKey, dataIndexKey}, nil
147169
}
148170

149171
// CloudEventToObjectKey generates a unique key for storing cloud events.

clickhouse/clickhouse_test.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func TestCloudEventToSlice(t *testing.T) {
3333

3434
// Test CloudEventToSlice
3535
slice := CloudEventToSlice(event)
36-
require.Len(t, slice, 10)
36+
require.Len(t, slice, 11)
3737

3838
// Verify the order and values of the slice
3939
assert.Equal(t, event.Subject, slice[0])
@@ -57,6 +57,9 @@ func TestCloudEventToSlice(t *testing.T) {
5757
// Verify index key
5858
expectedKey := CloudEventToObjectKey(event)
5959
assert.Equal(t, expectedKey, slice[9])
60+
61+
// data_index_key is empty for CloudEventToSlice (payload is not external)
62+
assert.Equal(t, "", slice[10])
6063
}
6164

6265
func TestCloudEventToSliceWithKey(t *testing.T) {
@@ -82,7 +85,7 @@ func TestCloudEventToSliceWithKey(t *testing.T) {
8285

8386
customKey := "custom-key"
8487
slice := CloudEventToSliceWithKey(event, customKey)
85-
require.Len(t, slice, 10)
88+
require.Len(t, slice, 11)
8689

8790
// Verify the order and values of the slice
8891
assert.Equal(t, event.Subject, slice[0])
@@ -105,6 +108,38 @@ func TestCloudEventToSliceWithKey(t *testing.T) {
105108

106109
// Verify custom key is used
107110
assert.Equal(t, customKey, slice[9])
111+
112+
// data_index_key is empty for CloudEventToSliceWithKey (payload is not external)
113+
assert.Equal(t, "", slice[10])
114+
}
115+
116+
func TestStoredEventToSlice(t *testing.T) {
117+
t.Parallel()
118+
119+
now := time.Now().UTC().Truncate(time.Millisecond)
120+
stored := &cloudevent.StoredEvent{
121+
RawEvent: cloudevent.RawEvent{
122+
CloudEventHeader: cloudevent.CloudEventHeader{
123+
ID: "test-id",
124+
Source: "test-source",
125+
Producer: "test-producer",
126+
SpecVersion: "1.0",
127+
Subject: "test-subject",
128+
Time: now,
129+
Type: "test.type",
130+
DataContentType: "image/jpeg",
131+
DataVersion: "v1",
132+
},
133+
},
134+
DataIndexKey: "payloads/abc123.jpg",
135+
}
136+
137+
slice := StoredEventToSlice(stored, "bundle/key#7")
138+
require.Len(t, slice, 11)
139+
140+
assert.Equal(t, stored.Subject, slice[0])
141+
assert.Equal(t, "bundle/key#7", slice[9])
142+
assert.Equal(t, "payloads/abc123.jpg", slice[10])
108143
}
109144

110145
func TestUnmarshalCloudEventSlice(t *testing.T) {
@@ -122,6 +157,7 @@ func TestUnmarshalCloudEventSlice(t *testing.T) {
122157
"v1",
123158
`{"extra1":"value1","extra2":123}`,
124159
"test-key",
160+
"test-data-key",
125161
}
126162

127163
// Marshal the slice to JSON
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- +goose Up
2+
-- +goose StatementBegin
3+
ALTER TABLE cloud_event ADD COLUMN data_index_key String COMMENT 'Key of the object holding the data payload. Empty for small events that are entirely stored in the object referenced by index_key.' AFTER index_key;
4+
-- +goose StatementEnd
5+
6+
-- +goose Down
7+
-- +goose StatementBegin
8+
ALTER TABLE cloud_event DROP COLUMN data_index_key;
9+
-- +goose StatementEnd

clickhouse/migrations/migrations_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func TestMigration(t *testing.T) {
5959
localch.DataVersionColumn,
6060
localch.ExtrasColumn,
6161
localch.IndexKeyColumn,
62+
localch.DataIndexKeyColumn,
6263
}
6364
assert.ElementsMatch(t, expectedCols, cols, "Columns do not match")
6465

parquet/decoder.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,19 @@ func (pr *Reader) NumRows() int64 {
3535

3636
// SeekToRow retrieves a single row by index.
3737
// The rowIndex must be in the range [0, NumRows()).
38-
func (pr *Reader) SeekToRow(rowIndex int64) (cloudevent.RawEvent, error) {
38+
func (pr *Reader) SeekToRow(rowIndex int64) (cloudevent.StoredEvent, error) {
3939
if rowIndex < 0 || rowIndex >= pr.reader.NumRows() {
40-
return cloudevent.RawEvent{}, fmt.Errorf("row index %d out of range [0, %d)", rowIndex, pr.reader.NumRows())
40+
return cloudevent.StoredEvent{}, fmt.Errorf("row index %d out of range [0, %d)", rowIndex, pr.reader.NumRows())
4141
}
4242

4343
if err := pr.reader.SeekToRow(rowIndex); err != nil {
44-
return cloudevent.RawEvent{}, fmt.Errorf("seeking to row %d: %w", rowIndex, err)
44+
return cloudevent.StoredEvent{}, fmt.Errorf("seeking to row %d: %w", rowIndex, err)
4545
}
4646

4747
var rows [1]ParquetRow
4848
_, err := pr.reader.Read(rows[:])
4949
if err != nil && err != io.EOF {
50-
return cloudevent.RawEvent{}, fmt.Errorf("reading parquet row: %w", err)
50+
return cloudevent.StoredEvent{}, fmt.Errorf("reading parquet row: %w", err)
5151
}
5252

5353
return convertRow(&rows[0])
@@ -60,7 +60,7 @@ func (pr *Reader) Close() error {
6060

6161
// Decode reads a parquet file from r and returns the decoded CloudEvents.
6262
// The size parameter must be the total size of the parquet data in bytes.
63-
func Decode(r io.ReaderAt, size int64) ([]cloudevent.RawEvent, error) {
63+
func Decode(r io.ReaderAt, size int64) ([]cloudevent.StoredEvent, error) {
6464
pr, err := OpenReader(r, size)
6565
if err != nil {
6666
return nil, err
@@ -78,7 +78,7 @@ func Decode(r io.ReaderAt, size int64) ([]cloudevent.RawEvent, error) {
7878
return nil, fmt.Errorf("reading parquet rows: %w", err)
7979
}
8080

81-
events := make([]cloudevent.RawEvent, len(rows))
81+
events := make([]cloudevent.StoredEvent, len(rows))
8282
for i := range rows {
8383
event, err := convertRow(&rows[i])
8484
if err != nil {
@@ -94,23 +94,23 @@ func Decode(r io.ReaderAt, size int64) ([]cloudevent.RawEvent, error) {
9494
// The rowIndex must be in the range [0, numRows).
9595
// For multiple seeks on the same file, use OpenReader instead to avoid
9696
// re-parsing the parquet footer on each call.
97-
func SeekToRow(r io.ReaderAt, size int64, rowIndex int64) (cloudevent.RawEvent, error) {
97+
func SeekToRow(r io.ReaderAt, size int64, rowIndex int64) (cloudevent.StoredEvent, error) {
9898
pr, err := OpenReader(r, size)
9999
if err != nil {
100-
return cloudevent.RawEvent{}, err
100+
return cloudevent.StoredEvent{}, err
101101
}
102102
defer func() { _ = pr.Close() }()
103103

104104
return pr.SeekToRow(rowIndex)
105105
}
106106

107-
// convertRow transforms a single ParquetRow back into a RawEvent.
107+
// convertRow transforms a single ParquetRow back into a StoredEvent.
108108
// This is the inverse of convertEvent in encoder.go.
109-
func convertRow(row *ParquetRow) (cloudevent.RawEvent, error) {
109+
func convertRow(row *ParquetRow) (cloudevent.StoredEvent, error) {
110110
var extras map[string]any
111111
if row.Extras != "" && row.Extras != "{}" {
112112
if err := json.Unmarshal([]byte(row.Extras), &extras); err != nil {
113-
return cloudevent.RawEvent{}, fmt.Errorf("unmarshaling extras: %w", err)
113+
return cloudevent.StoredEvent{}, fmt.Errorf("unmarshaling extras: %w", err)
114114
}
115115
}
116116

@@ -129,8 +129,11 @@ func convertRow(row *ParquetRow) (cloudevent.RawEvent, error) {
129129

130130
cloudevent.RestoreNonColumnFields(&header)
131131

132-
event := cloudevent.RawEvent{
133-
CloudEventHeader: header,
132+
event := cloudevent.StoredEvent{
133+
RawEvent: cloudevent.RawEvent{
134+
CloudEventHeader: header,
135+
},
136+
DataIndexKey: row.DataIndexKey,
134137
}
135138

136139
if len(row.DataBase64) > 0 {

parquet/encoder.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ func WithWriteBufferSize(n int) Option {
5353
// Encode writes events as Snappy-compressed Parquet to w. Each event is assigned
5454
// an index key in the format "objectKey#rowOffset". The returned map contains
5555
// the event index to index key mapping.
56-
func Encode(w io.Writer, events []cloudevent.RawEvent, objectKey string, opts ...Option) (map[int]string, error) {
56+
//
57+
// Each StoredEvent's DataIndexKey is written into the row so that a reader
58+
// holding a bundle alone can locate any externally-stored payload without
59+
// consulting ClickHouse. Leave DataIndexKey empty when the payload is inline
60+
// in Data / DataBase64.
61+
func Encode(w io.Writer, events []cloudevent.StoredEvent, objectKey string, opts ...Option) (map[int]string, error) {
5762
cfg := EncoderConfig{
5863
MaxRowsPerRowGroup: 10000,
5964
}
@@ -101,8 +106,8 @@ func Encode(w io.Writer, events []cloudevent.RawEvent, objectKey string, opts ..
101106
return indexKeys, nil
102107
}
103108

104-
// convertEvent transforms a single CloudEvent into a ParquetRow.
105-
func convertEvent(event *cloudevent.RawEvent) (ParquetRow, error) {
109+
// convertEvent transforms a single StoredEvent into a ParquetRow.
110+
func convertEvent(event *cloudevent.StoredEvent) (ParquetRow, error) {
106111
extras := cloudevent.AddNonColumnFieldsToExtras(&event.CloudEventHeader)
107112

108113
var extrasJSON []byte
@@ -126,6 +131,7 @@ func convertEvent(event *cloudevent.RawEvent) (ParquetRow, error) {
126131
DataVersion: event.DataVersion,
127132
Producer: event.Producer,
128133
Extras: string(extrasJSON),
134+
DataIndexKey: event.DataIndexKey,
129135
}
130136

131137
if event.DataBase64 != "" {

parquet/parquet_row.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ type ParquetRow struct {
1414
Extras string `parquet:"extras"`
1515
Data *string `parquet:"data,optional"`
1616
DataBase64 []byte `parquet:"data_base64,optional"`
17+
DataIndexKey string `parquet:"data_index_key,optional"`
1718
}

0 commit comments

Comments
 (0)