@@ -15,6 +15,9 @@ public class EtcdSyncPlugin : ISyncPlugin
1515 private SyncPluginConfig ? _config ;
1616 private HttpClient ? _httpClient ;
1717 private string _keyPrefix = "/agileconfig" ;
18+ private bool _allowOverwriteOtherData = false ;
19+ private int _maxTxnOperations = 500 ;
20+ private string _syncStrategy = "FullReplace" ;
1821
1922 public string Name => "etcd" ;
2023 public string DisplayName => "Etcd" ;
@@ -32,9 +35,21 @@ public Task<SyncPluginResult> InitializeAsync(SyncPluginConfig config)
3235 _config = config ;
3336
3437 var endpoints = config . Settings . GetValueOrDefault ( "endpoints" , "http://localhost:2379" ) ;
35- _keyPrefix = config . Settings . GetValueOrDefault ( "keyPrefix" , "/agileconfig" ) ;
38+ _keyPrefix = config . Settings . GetValueOrDefault ( "keyPrefix" , "/agileconfig" ) . TrimEnd ( '/' ) ;
39+ _allowOverwriteOtherData = bool . TryParse ( config . Settings . GetValueOrDefault ( "allowOverwriteOtherData" , "false" ) , out var a ) && a ;
40+ _maxTxnOperations = int . TryParse ( config . Settings . GetValueOrDefault ( "maxTxnOperations" , "500" ) , out var m ) && m > 0 ? m : 500 ;
41+ _syncStrategy = config . Settings . GetValueOrDefault ( "syncStrategy" , "FullReplace" ) ;
3642
37- _logger . LogInformation ( "Initializing Etcd plugin with endpoints: {Endpoints}" , endpoints ) ;
43+ // Validate key prefix
44+ if ( _keyPrefix == "/" || _keyPrefix . Length < 5 || ! _keyPrefix . StartsWith ( '/' ) )
45+ {
46+ var error = $ "Invalid key prefix '{ _keyPrefix } '. Prefix must start with '/' and be at least 5 characters long, cannot be root path '/'.";
47+ _logger . LogError ( error ) ;
48+ return Task . FromResult ( new SyncPluginResult { Success = false , Message = error } ) ;
49+ }
50+
51+ _logger . LogInformation ( "Initializing Etcd plugin with endpoints: {Endpoints}, keyPrefix: {KeyPrefix}, allowOverwriteOtherData: {AllowOverwrite}, maxTxnOperations: {MaxTxn}, syncStrategy: {SyncStrategy}" ,
52+ endpoints , _keyPrefix , _allowOverwriteOtherData , _maxTxnOperations , _syncStrategy ) ;
3853
3954 _httpClient = new HttpClient
4055 {
@@ -51,7 +66,7 @@ public Task<SyncPluginResult> InitializeAsync(SyncPluginConfig config)
5166 }
5267
5368 /// <summary>
54- /// Full sync: delete all + insert all
69+ /// Full sync: use etcd transaction to atomically replace all configs
5570 /// </summary>
5671 public async Task < SyncPluginResult > SyncAllAsync ( SyncContext [ ] contexts )
5772 {
@@ -67,54 +82,198 @@ public async Task<SyncPluginResult> SyncAllAsync(SyncContext[] contexts)
6782 var env = contexts [ 0 ] . Env ;
6883 var prefix = $ "{ _keyPrefix } /{ appId } /{ env } /";
6984
70- // Step 1: Delete all existing keys with prefix
71- await DeleteRangeAsync ( prefix ) ;
72- _logger . LogInformation ( "Deleted all keys with prefix {Prefix}" , prefix ) ;
85+ List < string > ? existingKeys = null ;
86+ // Safety check: verify existing keys are valid AgileConfig keys if overwrite is not allowed
87+ if ( ! _allowOverwriteOtherData )
88+ {
89+ existingKeys = await GetRangeKeysAsync ( prefix ) ;
90+ foreach ( var base64Key in existingKeys )
91+ {
92+ var key = System . Text . Encoding . UTF8 . GetString ( Convert . FromBase64String ( base64Key ) ) ;
93+ // Valid key format: {prefix}/{appId}/{env}/{group}/{key}
94+ var relativePath = key . Substring ( prefix . Length ) ;
95+ if ( string . IsNullOrEmpty ( relativePath ) || ! relativePath . Contains ( '/' ) || relativePath . StartsWith ( '/' ) || relativePath . EndsWith ( '/' ) )
96+ {
97+ var error = $ "Found invalid key '{ key } ' under prefix '{ prefix } ' that does not match AgileConfig format. To overwrite anyway, set 'allowOverwriteOtherData' to true.";
98+ _logger . LogError ( error ) ;
99+ return new SyncPluginResult { Success = false , Message = error } ;
100+ }
101+ }
102+ }
103+
104+ List < object > operations ;
105+ int deletedCount = 0 ;
106+ int addedCount = 0 ;
107+ int updatedCount = 0 ;
108+
109+ if ( _syncStrategy . Equals ( "Incremental" , StringComparison . OrdinalIgnoreCase ) )
110+ {
111+ // Incremental sync: compare existing configs with new ones
112+ var existingKvs = await GetRangeKvsAsync ( prefix ) ;
113+ var existingDict = existingKvs . ToDictionary (
114+ kv => System . Text . Encoding . UTF8 . GetString ( Convert . FromBase64String ( kv . key ) ) ,
115+ kv => System . Text . Encoding . UTF8 . GetString ( Convert . FromBase64String ( kv . value ) )
116+ ) ;
117+
118+ var newDict = contexts . ToDictionary (
119+ c => BuildKey ( c ) ,
120+ c => c . Value
121+ ) ;
122+
123+ operations = new List < object > ( ) ;
124+
125+ // Find keys to delete (exist in etcd but not in new configs)
126+ foreach ( var kv in existingDict )
127+ {
128+ if ( ! newDict . ContainsKey ( kv . Key ) )
129+ {
130+ operations . Add ( new
131+ {
132+ request_delete_range = new
133+ {
134+ key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( kv . Key ) )
135+ }
136+ } ) ;
137+ deletedCount ++ ;
138+ }
139+ }
140+
141+ // Find keys to add/update
142+ foreach ( var kv in newDict )
143+ {
144+ if ( ! existingDict . TryGetValue ( kv . Key , out var existingValue ) || existingValue != kv . Value )
145+ {
146+ operations . Add ( new
147+ {
148+ request_put = new
149+ {
150+ key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( kv . Key ) ) ,
151+ value = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( kv . Value ) )
152+ }
153+ } ) ;
154+ if ( ! existingDict . ContainsKey ( kv . Key ) )
155+ addedCount ++ ;
156+ else
157+ updatedCount ++ ;
158+ }
159+ }
160+
161+ _logger . LogInformation ( "Incremental sync: {Deleted} to delete, {Added} to add, {Updated} to update" ,
162+ deletedCount , addedCount , updatedCount ) ;
163+ }
164+ else
165+ {
166+ // Full replace sync: delete all then insert all
167+ operations = new List < object >
168+ {
169+ // Add single delete_range operation to delete all existing keys with prefix
170+ new
171+ {
172+ request_delete_range = new
173+ {
174+ key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( prefix ) ) ,
175+ range_end = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( GetRangeEnd ( prefix ) ) )
176+ }
177+ }
178+ } ;
179+ existingKeys ??= await GetRangeKeysAsync ( prefix ) ;
180+ deletedCount = existingKeys . Count ;
181+
182+ // Add put operations for all new configs
183+ foreach ( var context in contexts )
184+ {
185+ var key = BuildKey ( context ) ;
186+ operations . Add ( new
187+ {
188+ request_put = new
189+ {
190+ key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( key ) ) ,
191+ value = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( context . Value ) )
192+ }
193+ } ) ;
194+ addedCount ++ ;
195+ }
196+
197+ _logger . LogInformation ( "Full replace sync: {Deleted} old keys to delete, {Added} new keys to add" ,
198+ deletedCount , addedCount ) ;
199+ }
200+
201+ // Split operations into batches and execute
202+ var batchSize = _maxTxnOperations ;
203+ var totalBatches = ( int ) Math . Ceiling ( ( double ) operations . Count / batchSize ) ;
204+ var processed = 0 ;
73205
74- // Step 2: Insert all new configs
75- foreach ( var context in contexts )
206+ for ( var i = 0 ; i < totalBatches ; i ++ )
76207 {
77- var key = BuildKey ( context ) ;
78- await PutAsync ( key , context . Value ) ;
208+ var batch = operations . Skip ( i * batchSize ) . Take ( batchSize ) . ToList ( ) ;
209+ try
210+ {
211+ var batchTxn = new
212+ {
213+ success = batch
214+ } ;
215+
216+ var batchResponse = await _httpClient . PostAsJsonAsync ( "/v3/kv/txn" , batchTxn ) ;
217+ batchResponse . EnsureSuccessStatusCode ( ) ;
218+ processed += batch . Count ;
219+ _logger . LogDebug ( "Processed batch {Current}/{Total}, {Processed} operations completed" , i + 1 , totalBatches , processed ) ;
220+ }
221+ catch ( HttpRequestException ex ) when ( ex . Message . Contains ( "request too large" ) && batchSize > 10 )
222+ {
223+ // Auto reduce batch size on request too large error
224+ batchSize = Math . Max ( 10 , batchSize / 2 ) ;
225+ _logger . LogWarning ( "Request too large, reducing batch size to {BatchSize} and retrying batch" , batchSize ) ;
226+ i -- ; // Retry current batch
227+ }
79228 }
80229
81- _logger . LogInformation ( "Synced {Count} configs to etcd for app {AppId} env {Env}" ,
82- contexts . Length , appId , env ) ;
230+ _logger . LogInformation ( "Successfully synced configs to etcd for app {AppId} env {Env}: {Deleted} deleted, {Added} added, {Updated} updated in {Batches} batches " ,
231+ appId , env , deletedCount , addedCount , updatedCount , totalBatches ) ;
83232
84233 return new SyncPluginResult
85234 {
86235 Success = true ,
87- Message = $ "Synced { contexts . Length } configs "
236+ Message = $ "Synced: { deletedCount } deleted, { addedCount } added, { updatedCount } updated in { totalBatches } batches "
88237 } ;
89238 }
90239 catch ( Exception ex )
91240 {
92- _logger . LogError ( ex , "Failed to sync configs to etcd" ) ;
241+ _logger . LogError ( ex , "Failed to sync configs to etcd (transaction rolled back, no changes applied) " ) ;
93242 return new SyncPluginResult { Success = false , Message = ex . Message , Exception = ex } ;
94243 }
95244 }
96245
97246 /// <summary>
98- /// Put a key-value pair
247+ /// Get all keys with given prefix
99248 /// </summary>
100- private async Task PutAsync ( string key , string value )
249+ private async Task < List < string > > GetRangeKeysAsync ( string prefix )
101250 {
102- var request = new
251+ var keys = new List < string > ( ) ;
252+ var rangeRequest = new
103253 {
104- key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( key ) ) ,
105- value = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( value ) )
254+ key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( prefix ) ) ,
255+ range_end = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( GetRangeEnd ( prefix ) ) ) ,
256+ keys_only = true
106257 } ;
107258
108- var response = await _httpClient . PostAsJsonAsync ( "/v3/kv/put" , request ) ;
109- response . EnsureSuccessStatusCode ( ) ;
259+ var rangeResponse = await _httpClient . PostAsJsonAsync ( "/v3/kv/range" , rangeRequest ) ;
260+ rangeResponse . EnsureSuccessStatusCode ( ) ;
261+
262+ var rangeResult = await rangeResponse . Content . ReadFromJsonAsync < EtcdRangeResponse > ( ) ;
263+
264+ if ( rangeResult ? . kvs != null )
265+ {
266+ keys . AddRange ( rangeResult . kvs . Select ( kv => kv . key ) ) ;
267+ }
268+
269+ return keys ;
110270 }
111271
112272 /// <summary>
113- /// Delete all keys with given prefix
273+ /// Get all key-value pairs with given prefix
114274 /// </summary>
115- private async Task DeleteRangeAsync ( string prefix )
275+ private async Task < List < EtcdKv > > GetRangeKvsAsync ( string prefix )
116276 {
117- // Range request to get all keys with prefix
118277 var rangeRequest = new
119278 {
120279 key = Convert . ToBase64String ( System . Text . Encoding . UTF8 . GetBytes ( prefix ) ) ,
@@ -125,21 +284,8 @@ private async Task DeleteRangeAsync(string prefix)
125284 rangeResponse . EnsureSuccessStatusCode ( ) ;
126285
127286 var rangeResult = await rangeResponse . Content . ReadFromJsonAsync < EtcdRangeResponse > ( ) ;
128-
129- if ( rangeResult ? . kvs != null && rangeResult . kvs . Count > 0 )
130- {
131- // Delete each key
132- foreach ( var kvp in rangeResult . kvs )
133- {
134- var deleteRequest = new
135- {
136- key = kvp . key
137- } ;
138-
139- var deleteResponse = await _httpClient . PostAsJsonAsync ( "/v3/kv/deleterange" , deleteRequest ) ;
140- deleteResponse . EnsureSuccessStatusCode ( ) ;
141- }
142- }
287+
288+ return rangeResult ? . kvs ?? new List < EtcdKv > ( ) ;
143289 }
144290
145291 /// <summary>
0 commit comments