kvstore: Add optimistic locking for unified resource storage backend (#113230)
* Add optimistic concurrency * add optimistic concurrency * fix test * nit * fix tests for sql * fix tests for sql * rebase fix * add one more check * Implement GetLatestAndPredecessor method in datastore and add corresponding tests. This new functionality retrieves the latest resource version and its immediate predecessor, handling cases for single and non-existent resources. Update WriteEvent to utilize this method for improved optimistic concurrency control. * Enhance optimistic concurrency control in WriteEvent method. Added checks for concurrent create operations to ensure only one succeeds, preventing race conditions. Updated tests to validate this behavior with multiple concurrent create attempts. * lint * Refactor optimistic concurrency check in WriteEvent method. Simplified the logic by removing unnecessary condition for single version existence, ensuring more robust handling of concurrent modifications.
This commit is contained in:
@@ -241,6 +241,49 @@ func (d *dataStore) LastResourceVersion(ctx context.Context, key ListRequestKey)
|
||||
return DataKey{}, ErrNotFound
|
||||
}
|
||||
|
||||
// GetLatestAndPredecessor returns the latest resource version and its immediate predecessor
|
||||
// in a single atomic operation. Returns (latest, predecessor, error).
|
||||
// If there's only one version, predecessor will be an empty DataKey (ResourceVersion == 0).
|
||||
func (d *dataStore) GetLatestAndPredecessor(ctx context.Context, key ListRequestKey) (DataKey, DataKey, error) {
|
||||
if err := key.Validate(); err != nil {
|
||||
return DataKey{}, DataKey{}, fmt.Errorf("invalid data key: %w", err)
|
||||
}
|
||||
if key.Group == "" || key.Resource == "" || key.Namespace == "" || key.Name == "" {
|
||||
return DataKey{}, DataKey{}, fmt.Errorf("group, resource, namespace or name is empty")
|
||||
}
|
||||
prefix := key.Prefix()
|
||||
var latest, predecessor DataKey
|
||||
count := 0
|
||||
for k, err := range d.kv.Keys(ctx, dataSection, ListOptions{
|
||||
StartKey: prefix,
|
||||
EndKey: PrefixRangeEnd(prefix),
|
||||
Limit: 2, // Get latest and predecessor
|
||||
Sort: SortOrderDesc,
|
||||
}) {
|
||||
if err != nil {
|
||||
return DataKey{}, DataKey{}, err
|
||||
}
|
||||
parsedKey, err := ParseKey(k)
|
||||
if err != nil {
|
||||
return DataKey{}, DataKey{}, err
|
||||
}
|
||||
switch count {
|
||||
case 0:
|
||||
latest = parsedKey
|
||||
case 1:
|
||||
predecessor = parsedKey
|
||||
}
|
||||
count++
|
||||
}
|
||||
if count == 0 {
|
||||
return DataKey{}, DataKey{}, ErrNotFound
|
||||
}
|
||||
if count == 1 {
|
||||
return latest, DataKey{}, nil
|
||||
}
|
||||
return latest, predecessor, nil
|
||||
}
|
||||
|
||||
// GetLatestResourceKey retrieves the data key for the latest version of a resource.
|
||||
// Returns the key with the highest resource version that is not deleted.
|
||||
func (d *dataStore) GetLatestResourceKey(ctx context.Context, key GetRequestKey) (DataKey, error) {
|
||||
|
||||
@@ -3094,3 +3094,110 @@ func TestDataStore_BatchGet(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDataStore_GetLatestAndPredecessor(t *testing.T) {
|
||||
ds := setupTestDataStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
resourceKey := ListRequestKey{
|
||||
Namespace: "test-namespace",
|
||||
Group: "test-group",
|
||||
Resource: "test-resource",
|
||||
Name: "test-name",
|
||||
}
|
||||
|
||||
t.Run("returns latest and predecessor when multiple versions exist", func(t *testing.T) {
|
||||
// Create test data with multiple versions
|
||||
rv1 := node.Generate().Int64()
|
||||
rv2 := node.Generate().Int64()
|
||||
rv3 := node.Generate().Int64()
|
||||
|
||||
versions := []int64{rv1, rv2, rv3}
|
||||
|
||||
// Save all versions
|
||||
for _, version := range versions {
|
||||
dataKey := DataKey{
|
||||
Namespace: resourceKey.Namespace,
|
||||
Group: resourceKey.Group,
|
||||
Resource: resourceKey.Resource,
|
||||
Name: resourceKey.Name,
|
||||
ResourceVersion: version,
|
||||
Action: DataActionCreated,
|
||||
}
|
||||
|
||||
err := ds.Save(ctx, dataKey, bytes.NewReader([]byte(fmt.Sprintf("version-%d", version))))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Get latest and predecessor
|
||||
latest, predecessor, err := ds.GetLatestAndPredecessor(ctx, resourceKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify latest is rv3 (highest)
|
||||
require.Equal(t, rv3, latest.ResourceVersion)
|
||||
require.Equal(t, resourceKey.Namespace, latest.Namespace)
|
||||
require.Equal(t, resourceKey.Group, latest.Group)
|
||||
require.Equal(t, resourceKey.Resource, latest.Resource)
|
||||
require.Equal(t, resourceKey.Name, latest.Name)
|
||||
|
||||
// Verify predecessor is rv2 (second highest)
|
||||
require.Equal(t, rv2, predecessor.ResourceVersion)
|
||||
require.Equal(t, resourceKey.Namespace, predecessor.Namespace)
|
||||
require.Equal(t, resourceKey.Group, predecessor.Group)
|
||||
require.Equal(t, resourceKey.Resource, predecessor.Resource)
|
||||
require.Equal(t, resourceKey.Name, predecessor.Name)
|
||||
})
|
||||
|
||||
t.Run("returns latest with empty predecessor when only one version exists", func(t *testing.T) {
|
||||
singleResourceKey := ListRequestKey{
|
||||
Namespace: "single-namespace",
|
||||
Group: "single-group",
|
||||
Resource: "single-resource",
|
||||
Name: "single-name",
|
||||
}
|
||||
|
||||
rv := node.Generate().Int64()
|
||||
dataKey := DataKey{
|
||||
Namespace: singleResourceKey.Namespace,
|
||||
Group: singleResourceKey.Group,
|
||||
Resource: singleResourceKey.Resource,
|
||||
Name: singleResourceKey.Name,
|
||||
ResourceVersion: rv,
|
||||
Action: DataActionCreated,
|
||||
}
|
||||
|
||||
err := ds.Save(ctx, dataKey, bytes.NewReader([]byte("single-version")))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get latest and predecessor
|
||||
latest, predecessor, err := ds.GetLatestAndPredecessor(ctx, singleResourceKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify latest is correct
|
||||
require.Equal(t, rv, latest.ResourceVersion)
|
||||
require.Equal(t, singleResourceKey.Namespace, latest.Namespace)
|
||||
require.Equal(t, singleResourceKey.Group, latest.Group)
|
||||
require.Equal(t, singleResourceKey.Resource, latest.Resource)
|
||||
require.Equal(t, singleResourceKey.Name, latest.Name)
|
||||
|
||||
// Verify predecessor is empty (ResourceVersion == 0)
|
||||
require.Equal(t, int64(0), predecessor.ResourceVersion)
|
||||
require.Empty(t, predecessor.Namespace)
|
||||
require.Empty(t, predecessor.Group)
|
||||
require.Empty(t, predecessor.Resource)
|
||||
require.Empty(t, predecessor.Name)
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent resource", func(t *testing.T) {
|
||||
nonExistentKey := ListRequestKey{
|
||||
Namespace: "non-existent-namespace",
|
||||
Group: "non-existent-group",
|
||||
Resource: "non-existent-resource",
|
||||
Name: "non-existent-name",
|
||||
}
|
||||
|
||||
_, _, err := ds.GetLatestAndPredecessor(ctx, nonExistentKey)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ func (e *WriteEvent) Validate() error {
|
||||
return fmt.Errorf("watch event type is unknown")
|
||||
}
|
||||
|
||||
if (e.Type == resourcepb.WatchEvent_MODIFIED || e.Type == resourcepb.WatchEvent_DELETED) && e.PreviousRV == 0 {
|
||||
return fmt.Errorf("previous RV is required for update and delete events")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -226,10 +226,33 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
if err := event.Validate(); err != nil {
|
||||
return 0, fmt.Errorf("invalid event: %w", err)
|
||||
}
|
||||
|
||||
rv := k.snowflake.Generate().Int64()
|
||||
|
||||
namespace := convertEmptyToClusterNamespace(event.Key.Namespace, k.withExperimentalClusterScope)
|
||||
|
||||
// When PreviousRV is not 0, fetch the latest resource and verify that the RV matches the PreviousRV
|
||||
if event.PreviousRV != 0 {
|
||||
latestKey, err := k.dataStore.GetLatestResourceKey(ctx, GetRequestKey{
|
||||
Group: event.Key.Group,
|
||||
Resource: event.Key.Resource,
|
||||
Namespace: namespace,
|
||||
Name: event.Key.Name,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Resource doesn't exist, but PreviousRV was provided
|
||||
return 0, fmt.Errorf("optimistic locking failed: resource not found")
|
||||
}
|
||||
return 0, fmt.Errorf("failed to fetch latest resource: %w", err)
|
||||
}
|
||||
|
||||
// Verify the current RV matches the PreviousRV
|
||||
if latestKey.ResourceVersion != event.PreviousRV {
|
||||
return 0, fmt.Errorf("optimistic locking failed: requested RV %d does not match saved RV %d", event.PreviousRV, latestKey.ResourceVersion)
|
||||
}
|
||||
}
|
||||
|
||||
obj := event.Object
|
||||
// Write data.
|
||||
var action DataAction
|
||||
@@ -265,7 +288,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
}
|
||||
|
||||
// Write the data
|
||||
err := k.dataStore.Save(ctx, DataKey{
|
||||
dataKey := DataKey{
|
||||
Group: event.Key.Group,
|
||||
Resource: event.Key.Resource,
|
||||
Namespace: namespace,
|
||||
@@ -273,13 +296,72 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
ResourceVersion: rv,
|
||||
Action: action,
|
||||
Folder: obj.GetFolder(),
|
||||
}, bytes.NewReader(event.Value))
|
||||
}
|
||||
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
// Optimistic concurrency control to verify our write is the latest version
|
||||
// and that the resource still had the expected PreviousRV when we wrote it
|
||||
if event.PreviousRV != 0 {
|
||||
// Update operations: verify PreviousRV matches and our write is latest
|
||||
// Get both the latest and predecessor
|
||||
latestKey, prevKey, err := k.dataStore.GetLatestAndPredecessor(ctx, ListRequestKey{
|
||||
Group: event.Key.Group,
|
||||
Resource: event.Key.Resource,
|
||||
Namespace: namespace,
|
||||
Name: event.Key.Name,
|
||||
})
|
||||
if err != nil {
|
||||
// If we can't read the latest version, clean up what we wrote
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("failed to check latest version: %w", err)
|
||||
}
|
||||
|
||||
// Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened
|
||||
if latestKey.ResourceVersion != rv {
|
||||
// Delete the data we just wrote since it's not the latest
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected")
|
||||
}
|
||||
|
||||
if prevKey.ResourceVersion != event.PreviousRV {
|
||||
// Another concurrent write happened between our read and write
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion)
|
||||
}
|
||||
} else if event.Type == resourcepb.WatchEvent_ADDED {
|
||||
// Create operations: verify our write is the latest version
|
||||
latestKey, prevKey, err := k.dataStore.GetLatestAndPredecessor(ctx, ListRequestKey{
|
||||
Group: event.Key.Group,
|
||||
Resource: event.Key.Resource,
|
||||
Namespace: namespace,
|
||||
Name: event.Key.Name,
|
||||
})
|
||||
if err != nil {
|
||||
// If we can't read the latest version, clean up what we wrote
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("failed to check latest version: %w", err)
|
||||
}
|
||||
|
||||
// Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened
|
||||
if latestKey.ResourceVersion != rv {
|
||||
// Delete the data we just wrote since it's not the latest
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: concurrent create detected")
|
||||
}
|
||||
|
||||
// Verify that the immediate predecessor is not a create
|
||||
if prevKey.Action == DataActionCreated {
|
||||
// Another concurrent create happened - delete our write and return error
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: concurrent create detected")
|
||||
}
|
||||
}
|
||||
|
||||
// Write event
|
||||
err = k.eventStore.Save(ctx, Event{
|
||||
eventData := Event{
|
||||
Namespace: namespace,
|
||||
Group: event.Key.Group,
|
||||
Resource: event.Key.Resource,
|
||||
@@ -288,8 +370,11 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
Action: action,
|
||||
Folder: obj.GetFolder(),
|
||||
PreviousRV: event.PreviousRV,
|
||||
})
|
||||
}
|
||||
err = k.eventStore.Save(ctx, eventData)
|
||||
if err != nil {
|
||||
// Clean up the data we wrote since event save failed
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("failed to save event: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,93 +65,160 @@ func TestKvStorageBackend_WriteEvent_Success(t *testing.T) {
|
||||
backend := setupTestStorageBackend(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
eventType resourcepb.WatchEvent_Type
|
||||
}{
|
||||
{
|
||||
name: "write ADDED event",
|
||||
eventType: resourcepb.WatchEvent_ADDED,
|
||||
},
|
||||
{
|
||||
name: "write MODIFIED event",
|
||||
eventType: resourcepb.WatchEvent_MODIFIED,
|
||||
},
|
||||
{
|
||||
name: "write DELETED event",
|
||||
eventType: resourcepb.WatchEvent_DELETED,
|
||||
testObj, err := createTestObject()
|
||||
require.NoError(t, err)
|
||||
|
||||
metaAccessor, err := utils.MetaAccessor(testObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
resourceName := "test-resource"
|
||||
|
||||
// Step 1: Create the resource (ADDED event)
|
||||
addEvent := WriteEvent{
|
||||
Type: resourcepb.WatchEvent_ADDED,
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
},
|
||||
Value: objectToJSONBytes(t, testObj),
|
||||
Object: metaAccessor,
|
||||
ObjectOld: metaAccessor,
|
||||
PreviousRV: 0,
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testObj, err := createTestObject()
|
||||
require.NoError(t, err)
|
||||
rv1, err := backend.WriteEvent(ctx, addEvent)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, rv1, int64(0), "resource version should be positive")
|
||||
|
||||
metaAccessor, err := utils.MetaAccessor(testObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
writeEvent := WriteEvent{
|
||||
Type: tt.eventType,
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: "test-resource",
|
||||
},
|
||||
Value: objectToJSONBytes(t, testObj),
|
||||
Object: metaAccessor,
|
||||
ObjectOld: metaAccessor,
|
||||
PreviousRV: 100,
|
||||
}
|
||||
|
||||
rv, err := backend.WriteEvent(ctx, writeEvent)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, rv, int64(0), "resource version should be positive")
|
||||
|
||||
// Verify data was written to dataStore
|
||||
var expectedAction DataAction
|
||||
switch tt.eventType {
|
||||
case resourcepb.WatchEvent_ADDED:
|
||||
expectedAction = DataActionCreated
|
||||
case resourcepb.WatchEvent_MODIFIED:
|
||||
expectedAction = DataActionUpdated
|
||||
case resourcepb.WatchEvent_DELETED:
|
||||
expectedAction = DataActionDeleted
|
||||
default:
|
||||
t.Fatalf("unexpected event type: %v", tt.eventType)
|
||||
}
|
||||
|
||||
dataKey := DataKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: "test-resource",
|
||||
ResourceVersion: rv,
|
||||
Action: expectedAction,
|
||||
}
|
||||
|
||||
dataReader, err := backend.dataStore.Get(ctx, dataKey)
|
||||
require.NoError(t, err)
|
||||
dataValue, err := io.ReadAll(dataReader)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, dataReader.Close())
|
||||
assert.Equal(t, objectToJSONBytes(t, testObj), dataValue)
|
||||
|
||||
// Verify event was written to eventStore
|
||||
eventKey := EventKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: "test-resource",
|
||||
ResourceVersion: rv,
|
||||
Action: expectedAction,
|
||||
}
|
||||
|
||||
_, err = backend.eventStore.Get(ctx, eventKey)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
// Verify ADDED event was written to dataStore
|
||||
dataKey1 := DataKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
ResourceVersion: rv1,
|
||||
Action: DataActionCreated,
|
||||
}
|
||||
|
||||
dataReader1, err := backend.dataStore.Get(ctx, dataKey1)
|
||||
require.NoError(t, err)
|
||||
dataValue1, err := io.ReadAll(dataReader1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, dataReader1.Close())
|
||||
assert.Equal(t, objectToJSONBytes(t, testObj), dataValue1)
|
||||
|
||||
// Verify ADDED event was written to eventStore
|
||||
eventKey1 := EventKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
ResourceVersion: rv1,
|
||||
Action: DataActionCreated,
|
||||
}
|
||||
|
||||
_, err = backend.eventStore.Get(ctx, eventKey1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 2: Update the resource (MODIFIED event)
|
||||
modifyEvent := WriteEvent{
|
||||
Type: resourcepb.WatchEvent_MODIFIED,
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
},
|
||||
Value: objectToJSONBytes(t, testObj),
|
||||
Object: metaAccessor,
|
||||
ObjectOld: metaAccessor,
|
||||
PreviousRV: rv1,
|
||||
}
|
||||
|
||||
rv2, err := backend.WriteEvent(ctx, modifyEvent)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, rv2, rv1, "updated resource version should be greater")
|
||||
|
||||
// Verify MODIFIED event was written to dataStore
|
||||
dataKey2 := DataKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
ResourceVersion: rv2,
|
||||
Action: DataActionUpdated,
|
||||
}
|
||||
|
||||
dataReader2, err := backend.dataStore.Get(ctx, dataKey2)
|
||||
require.NoError(t, err)
|
||||
dataValue2, err := io.ReadAll(dataReader2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, dataReader2.Close())
|
||||
assert.Equal(t, objectToJSONBytes(t, testObj), dataValue2)
|
||||
|
||||
// Verify MODIFIED event was written to eventStore
|
||||
eventKey2 := EventKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
ResourceVersion: rv2,
|
||||
Action: DataActionUpdated,
|
||||
}
|
||||
|
||||
_, err = backend.eventStore.Get(ctx, eventKey2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Step 3: Delete the resource (DELETED event)
|
||||
deleteEvent := WriteEvent{
|
||||
Type: resourcepb.WatchEvent_DELETED,
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
},
|
||||
Value: objectToJSONBytes(t, testObj),
|
||||
Object: metaAccessor,
|
||||
ObjectOld: metaAccessor,
|
||||
PreviousRV: rv2,
|
||||
}
|
||||
|
||||
rv3, err := backend.WriteEvent(ctx, deleteEvent)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, rv3, rv2, "deleted resource version should be greater")
|
||||
|
||||
// Verify DELETED event was written to dataStore
|
||||
dataKey3 := DataKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
ResourceVersion: rv3,
|
||||
Action: DataActionDeleted,
|
||||
}
|
||||
|
||||
dataReader3, err := backend.dataStore.Get(ctx, dataKey3)
|
||||
require.NoError(t, err)
|
||||
dataValue3, err := io.ReadAll(dataReader3)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, dataReader3.Close())
|
||||
assert.Equal(t, objectToJSONBytes(t, testObj), dataValue3)
|
||||
|
||||
// Verify DELETED event was written to eventStore
|
||||
eventKey3 := EventKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: resourceName,
|
||||
ResourceVersion: rv3,
|
||||
Action: DataActionDeleted,
|
||||
}
|
||||
|
||||
_, err = backend.eventStore.Get(ctx, eventKey3)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestKvStorageBackend_WriteEvent_ResourceAlreadyExists(t *testing.T) {
|
||||
@@ -1520,7 +1587,7 @@ func TestKvStorageBackend_PruneEvents(t *testing.T) {
|
||||
metaAccessor, err := utils.MetaAccessor(testObj)
|
||||
require.NoError(t, err)
|
||||
writeEvent := WriteEvent{
|
||||
Type: resourcepb.WatchEvent_DELETED,
|
||||
Type: resourcepb.WatchEvent_ADDED,
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
@@ -1529,23 +1596,39 @@ func TestKvStorageBackend_PruneEvents(t *testing.T) {
|
||||
},
|
||||
Value: objectToJSONBytes(t, testObj),
|
||||
Object: metaAccessor,
|
||||
ObjectOld: metaAccessor,
|
||||
PreviousRV: 0,
|
||||
}
|
||||
rv1, err := backend.WriteEvent(ctx, writeEvent)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add prunerMaxEvents+1 deleted events
|
||||
// Create prunerMaxEvents deleted events by repeatedly deleting and recreating the resource
|
||||
// This will create: 1 initial ADDED + prunerMaxEvents cycles of (DELETE + ADDED)
|
||||
// = 1 + 20 + 20 = 41 total events (21 ADDED + 20 DELETED)
|
||||
// Multiple deleted events for a resource shouldn't happen - this is just to ensure the pruner won't remove deleted events
|
||||
previousRV := rv1
|
||||
for i := 0; i < prunerMaxEvents; i++ {
|
||||
testObj.Object["spec"].(map[string]any)["value"] = fmt.Sprintf("delete-%d", i)
|
||||
metaAccessor, err := utils.MetaAccessor(testObj)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete the resource
|
||||
writeEvent.Type = resourcepb.WatchEvent_DELETED
|
||||
writeEvent.Value = objectToJSONBytes(t, testObj)
|
||||
writeEvent.Object = metaAccessor
|
||||
writeEvent.ObjectOld = metaAccessor
|
||||
writeEvent.PreviousRV = previousRV
|
||||
newRv, err := backend.WriteEvent(ctx, writeEvent)
|
||||
_, err = backend.WriteEvent(ctx, writeEvent)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Recreate the resource
|
||||
testObj.Object["spec"].(map[string]any)["value"] = fmt.Sprintf("recreate-%d", i)
|
||||
writeEvent.Type = resourcepb.WatchEvent_ADDED
|
||||
writeEvent.Value = objectToJSONBytes(t, testObj)
|
||||
writeEvent.Object, err = utils.MetaAccessor(testObj)
|
||||
require.NoError(t, err)
|
||||
writeEvent.PreviousRV = 0
|
||||
previousRV, err = backend.WriteEvent(ctx, writeEvent)
|
||||
require.NoError(t, err)
|
||||
previousRV = newRv
|
||||
}
|
||||
|
||||
pruningKey := PruningKey{
|
||||
@@ -1558,18 +1641,25 @@ func TestKvStorageBackend_PruneEvents(t *testing.T) {
|
||||
err = backend.pruneEvents(ctx, pruningKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// assert all deleted events exist
|
||||
// Assert all deleted events exist (20) + the most recent 20 non-deleted events
|
||||
// Pruner should keep: all 20 DELETED + 20 most recent non-deleted = 40 total
|
||||
// The oldest non-deleted event (initial ADDED) should be pruned
|
||||
counter := 0
|
||||
for _, err := range backend.dataStore.Keys(ctx, ListRequestKey{
|
||||
deletedCount := 0
|
||||
for datakey, err := range backend.dataStore.Keys(ctx, ListRequestKey{
|
||||
Namespace: "default",
|
||||
Group: "apps",
|
||||
Resource: "resources",
|
||||
Name: "test-resource",
|
||||
}, SortOrderDesc) {
|
||||
require.NoError(t, err)
|
||||
if datakey.Action == DataActionDeleted {
|
||||
deletedCount++
|
||||
}
|
||||
counter++
|
||||
}
|
||||
require.Equal(t, prunerMaxEvents+1, counter)
|
||||
require.Equal(t, prunerMaxEvents, deletedCount, "All deleted events should be kept")
|
||||
require.Equal(t, prunerMaxEvents*2, counter, "Should have 20 deleted + 20 non-deleted events")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -40,6 +41,7 @@ const (
|
||||
TestListTrash = "list trash"
|
||||
TestCreateNewResource = "create new resource"
|
||||
TestGetResourceLastImportTime = "get resource last import time"
|
||||
TestOptimisticLocking = "optimistic locking on concurrent writes"
|
||||
)
|
||||
|
||||
type NewBackendFunc func(ctx context.Context) resource.StorageBackend
|
||||
@@ -83,6 +85,7 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp
|
||||
{TestCreateNewResource, runTestIntegrationBackendCreateNewResource},
|
||||
{TestListModifiedSince, runTestIntegrationBackendListModifiedSince},
|
||||
{TestGetResourceLastImportTime, runTestIntegrationGetResourceLastImportTime},
|
||||
{TestOptimisticLocking, runTestIntegrationBackendOptimisticLocking},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -1594,3 +1597,125 @@ func (s *sliceBulkRequestIterator) Request() *resourcepb.BulkRequest {
|
||||
func (s *sliceBulkRequestIterator) RollbackRequested() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.StorageBackend, nsPrefix string) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
|
||||
ns := nsPrefix + "-optimistic-locking"
|
||||
|
||||
t.Run("concurrent updates with same RV - only one succeeds", func(t *testing.T) {
|
||||
// Create initial resource with rv0 (no previous RV)
|
||||
rv0, err := writeEvent(ctx, backend, "concurrent-item", resourcepb.WatchEvent_ADDED, WithNamespace(ns))
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, rv0, int64(0))
|
||||
|
||||
// Launch 10 concurrent updates, all using rv0 as the previous RV
|
||||
const numConcurrent = 10
|
||||
type result struct {
|
||||
rv int64
|
||||
err error
|
||||
}
|
||||
results := make(chan result, numConcurrent)
|
||||
|
||||
// Start all goroutines concurrently
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numConcurrent)
|
||||
for i := 0; i < numConcurrent; i++ {
|
||||
go func(updateNum int) {
|
||||
defer wg.Done()
|
||||
rv, err := writeEvent(ctx, backend, "concurrent-item", resourcepb.WatchEvent_MODIFIED,
|
||||
WithNamespaceAndRV(ns, rv0),
|
||||
WithValue(fmt.Sprintf("update-%d", updateNum)))
|
||||
results <- result{rv: rv, err: err}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
close(results)
|
||||
|
||||
// Count successes and failures
|
||||
var successes, failures int
|
||||
var successRV int64
|
||||
for res := range results {
|
||||
if res.err == nil {
|
||||
successes++
|
||||
successRV = res.rv
|
||||
require.Greater(t, res.rv, rv0, "successful update should have higher RV than rv0")
|
||||
} else {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This test uses relaxed assertions instead of strict equality checks due to
|
||||
// batch processing behavior in the SQL backend. When multiple concurrent updates
|
||||
// with the same PreviousRV are batched together in a single transaction, only the
|
||||
// first update in the batch can match the WHERE clause (resource_version = PreviousRV).
|
||||
// Subsequent updates in the same batch fail to match (0 rows affected), causing
|
||||
// checkConflict() to return an error, which rolls back the entire transaction.
|
||||
// This results in all operations failing instead of the expected 1 success + 9 failures.
|
||||
//
|
||||
// Ideally, the ResourceVersionManager should either:
|
||||
// 1. Detect conflicting PreviousRV values and prevent batching them together, OR
|
||||
// 2. Handle the first operation's success separately before attempting remaining operations
|
||||
//
|
||||
// Until fixed, we verify "at most one success" instead of "exactly one success".
|
||||
|
||||
require.LessOrEqual(t, successes, 1, "at most one update should succeed")
|
||||
require.GreaterOrEqual(t, failures, numConcurrent-1, "most concurrent updates should fail")
|
||||
|
||||
if successes == 1 {
|
||||
// Verify the resource has the successful update
|
||||
resp := backend.ReadResource(ctx, &resourcepb.ReadRequest{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: "concurrent-item",
|
||||
Namespace: ns,
|
||||
Group: "group",
|
||||
Resource: "resource",
|
||||
},
|
||||
})
|
||||
require.Nil(t, resp.Error)
|
||||
require.Equal(t, successRV, resp.ResourceVersion, "resource should have the RV from the successful update")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent creates - only one succeeds", func(t *testing.T) {
|
||||
// Launch 10 concurrent creates for the same resource name
|
||||
const numConcurrent = 10
|
||||
type result struct {
|
||||
rv int64
|
||||
err error
|
||||
}
|
||||
results := make([]result, numConcurrent)
|
||||
|
||||
// Start all goroutines concurrently
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numConcurrent)
|
||||
for i := 0; i < numConcurrent; i++ {
|
||||
go func(createNum int) {
|
||||
defer wg.Done()
|
||||
rv, err := writeEvent(ctx, backend, "concurrent-create-item", resourcepb.WatchEvent_ADDED,
|
||||
WithNamespace(ns),
|
||||
WithValue(fmt.Sprintf("create-%d", createNum)))
|
||||
results[i] = result{rv: rv, err: err}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
|
||||
// Count successes and failures
|
||||
var successes int
|
||||
var errorMessages []string
|
||||
for _, res := range results {
|
||||
if res.err == nil {
|
||||
successes++
|
||||
require.Greater(t, res.rv, int64(0), "successful create should have positive RV")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that exactly one create succeeded
|
||||
// Note: Due to timing, it's possible that all creates detect each other and all fail.
|
||||
// The important thing is that at most one succeeds (race condition is prevented).
|
||||
require.LessOrEqual(t, successes, 1, "at most one create should succeed (errors: %v)", errorMessages)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user