Skip to content

Commit 61626cb

Browse files
hemanandrclaude
andcommitted
feat: implement Settings Service and watermark tracking (Issue #12)
- Add ISettingsService interface for key-value settings management - Implement SettingsService with memory caching and thread-safe operations - Add typed getter/setter methods for DateTimeOffset, DateOnly, int, etc. - Implement watermark helper methods for rollup job tracking: - GetLastRollup15mTimestampAsync/SetLastRollup15mTimestampAsync - GetLastRollupDailyDateAsync/SetLastRollupDailyDateAsync - GetLastPruneTimestampAsync/SetLastPruneTimestampAsync - Register SettingsService and MemoryCache in DI container - Add batch operations for multiple settings - Settings table already exists from Issue #10, leveraged existing entity Functional testing confirmed: - CRUD operations working correctly - Typed conversions for DateTimeOffset and DateOnly - Cache invalidation and thread-safe access - Integration with existing seeded settings Completes Phase 2 data layer foundation. Enables rollup jobs (Issues #27, #28). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 8a96096 commit 61626cb

25 files changed

+3440
-53
lines changed

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
"permissions": {
33
"allow": [
44
"Bash(dotnet format:*)",
5-
"Bash(dotnet build)"
5+
"Bash(dotnet build)",
6+
"Bash(dotnet add package:*)",
7+
"Bash(curl:*)",
8+
"Bash(taskkill:*)"
69
],
710
"deny": [],
811
"ask": []

ThingConnect.Pulse.Server/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ public static void Main(string[] args)
1616
builder.Services.AddDbContext<PulseDbContext>(options =>
1717
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
1818

19+
// Add memory cache for settings service
20+
builder.Services.AddMemoryCache();
21+
1922
// Add configuration services
2023
builder.Services.AddSingleton<ConfigParser>();
2124
builder.Services.AddScoped<IConfigurationService, ConfigurationService>();
25+
builder.Services.AddScoped<ISettingsService, SettingsService>();
2226

2327
builder.Services.AddControllers(options =>
2428
{
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Threading.Tasks;
4+
5+
namespace ThingConnect.Pulse.Server.Services;
6+
7+
public interface ISettingsService
8+
{
9+
Task<string?> GetAsync(string key);
10+
Task SetAsync(string key, string value);
11+
Task<T?> GetAsync<T>(string key) where T : struct;
12+
Task<T?> GetAsync<T>(string key, T defaultValue) where T : struct;
13+
Task SetAsync<T>(string key, T value) where T : struct;
14+
Task<Dictionary<string, string>> GetManyAsync(params string[] keys);
15+
Task SetManyAsync(Dictionary<string, string> values);
16+
Task DeleteAsync(string key);
17+
Task<bool> ExistsAsync(string key);
18+
19+
Task<DateTimeOffset?> GetLastRollup15mTimestampAsync();
20+
Task SetLastRollup15mTimestampAsync(DateTimeOffset timestamp);
21+
22+
Task<DateOnly?> GetLastRollupDailyDateAsync();
23+
Task SetLastRollupDailyDateAsync(DateOnly date);
24+
25+
Task<DateTimeOffset?> GetLastPruneTimestampAsync();
26+
Task SetLastPruneTimestampAsync(DateTimeOffset timestamp);
27+
28+
Task<string?> GetVersionAsync();
29+
Task SetVersionAsync(string version);
30+
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.Extensions.Caching.Memory;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Globalization;
6+
using System.Linq;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using ThingConnect.Pulse.Server.Data;
10+
11+
namespace ThingConnect.Pulse.Server.Services;
12+
13+
public sealed class SettingsService : ISettingsService
14+
{
15+
private readonly PulseDbContext _context;
16+
private readonly IMemoryCache _cache;
17+
private readonly SemaphoreSlim _semaphore;
18+
private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(5);
19+
20+
private const string LastRollup15mKey = "last_rollup_15m";
21+
private const string LastRollupDailyKey = "last_rollup_daily";
22+
private const string LastPruneKey = "last_prune";
23+
private const string VersionKey = "version";
24+
25+
public SettingsService(PulseDbContext context, IMemoryCache cache)
26+
{
27+
_context = context;
28+
_cache = cache;
29+
_semaphore = new SemaphoreSlim(1, 1);
30+
}
31+
32+
public async Task<string?> GetAsync(string key)
33+
{
34+
var cacheKey = $"setting:{key}";
35+
36+
if (_cache.TryGetValue(cacheKey, out string? cachedValue))
37+
{
38+
return cachedValue;
39+
}
40+
41+
await _semaphore.WaitAsync();
42+
try
43+
{
44+
if (_cache.TryGetValue(cacheKey, out cachedValue))
45+
{
46+
return cachedValue;
47+
}
48+
49+
var setting = await _context.Settings
50+
.FirstOrDefaultAsync(s => s.K == key);
51+
52+
var value = setting?.V;
53+
_cache.Set(cacheKey, value, _cacheExpiration);
54+
55+
return value;
56+
}
57+
finally
58+
{
59+
_semaphore.Release();
60+
}
61+
}
62+
63+
public async Task SetAsync(string key, string value)
64+
{
65+
await _semaphore.WaitAsync();
66+
try
67+
{
68+
var setting = await _context.Settings
69+
.FirstOrDefaultAsync(s => s.K == key);
70+
71+
if (setting == null)
72+
{
73+
setting = new Setting { K = key, V = value };
74+
_context.Settings.Add(setting);
75+
}
76+
else
77+
{
78+
setting.V = value;
79+
}
80+
81+
await _context.SaveChangesAsync();
82+
83+
var cacheKey = $"setting:{key}";
84+
_cache.Set(cacheKey, value, _cacheExpiration);
85+
}
86+
finally
87+
{
88+
_semaphore.Release();
89+
}
90+
}
91+
92+
public async Task<T?> GetAsync<T>(string key) where T : struct
93+
{
94+
var stringValue = await GetAsync(key);
95+
if (string.IsNullOrEmpty(stringValue))
96+
{
97+
return null;
98+
}
99+
100+
return ConvertToType<T>(stringValue);
101+
}
102+
103+
public async Task<T?> GetAsync<T>(string key, T defaultValue) where T : struct
104+
{
105+
var result = await GetAsync<T>(key);
106+
return result ?? defaultValue;
107+
}
108+
109+
public async Task SetAsync<T>(string key, T value) where T : struct
110+
{
111+
var stringValue = ConvertToString(value);
112+
await SetAsync(key, stringValue);
113+
}
114+
115+
public async Task<Dictionary<string, string>> GetManyAsync(params string[] keys)
116+
{
117+
var result = new Dictionary<string, string>();
118+
119+
foreach (var key in keys)
120+
{
121+
var value = await GetAsync(key);
122+
if (value != null)
123+
{
124+
result[key] = value;
125+
}
126+
}
127+
128+
return result;
129+
}
130+
131+
public async Task SetManyAsync(Dictionary<string, string> values)
132+
{
133+
await _semaphore.WaitAsync();
134+
try
135+
{
136+
var existingSettings = await _context.Settings
137+
.Where(s => values.Keys.Contains(s.K))
138+
.ToListAsync();
139+
140+
foreach (var kvp in values)
141+
{
142+
var existing = existingSettings.FirstOrDefault(s => s.K == kvp.Key);
143+
if (existing == null)
144+
{
145+
_context.Settings.Add(new Setting { K = kvp.Key, V = kvp.Value });
146+
}
147+
else
148+
{
149+
existing.V = kvp.Value;
150+
}
151+
152+
var cacheKey = $"setting:{kvp.Key}";
153+
_cache.Set(cacheKey, kvp.Value, _cacheExpiration);
154+
}
155+
156+
await _context.SaveChangesAsync();
157+
}
158+
finally
159+
{
160+
_semaphore.Release();
161+
}
162+
}
163+
164+
public async Task DeleteAsync(string key)
165+
{
166+
await _semaphore.WaitAsync();
167+
try
168+
{
169+
var setting = await _context.Settings
170+
.FirstOrDefaultAsync(s => s.K == key);
171+
172+
if (setting != null)
173+
{
174+
_context.Settings.Remove(setting);
175+
await _context.SaveChangesAsync();
176+
}
177+
178+
var cacheKey = $"setting:{key}";
179+
_cache.Remove(cacheKey);
180+
}
181+
finally
182+
{
183+
_semaphore.Release();
184+
}
185+
}
186+
187+
public async Task<bool> ExistsAsync(string key)
188+
{
189+
var value = await GetAsync(key);
190+
return value != null;
191+
}
192+
193+
public async Task<DateTimeOffset?> GetLastRollup15mTimestampAsync()
194+
{
195+
return await GetAsync<DateTimeOffset>(LastRollup15mKey);
196+
}
197+
198+
public async Task SetLastRollup15mTimestampAsync(DateTimeOffset timestamp)
199+
{
200+
await SetAsync(LastRollup15mKey, timestamp);
201+
}
202+
203+
public async Task<DateOnly?> GetLastRollupDailyDateAsync()
204+
{
205+
return await GetAsync<DateOnly>(LastRollupDailyKey);
206+
}
207+
208+
public async Task SetLastRollupDailyDateAsync(DateOnly date)
209+
{
210+
await SetAsync(LastRollupDailyKey, date);
211+
}
212+
213+
public async Task<DateTimeOffset?> GetLastPruneTimestampAsync()
214+
{
215+
return await GetAsync<DateTimeOffset>(LastPruneKey);
216+
}
217+
218+
public async Task SetLastPruneTimestampAsync(DateTimeOffset timestamp)
219+
{
220+
await SetAsync(LastPruneKey, timestamp);
221+
}
222+
223+
public async Task<string?> GetVersionAsync()
224+
{
225+
return await GetAsync(VersionKey);
226+
}
227+
228+
public async Task SetVersionAsync(string version)
229+
{
230+
await SetAsync(VersionKey, version);
231+
}
232+
233+
private static T? ConvertToType<T>(string value) where T : struct
234+
{
235+
try
236+
{
237+
if (typeof(T) == typeof(DateTimeOffset))
238+
{
239+
if (DateTimeOffset.TryParse(value, null, DateTimeStyles.RoundtripKind, out var dateTimeOffset))
240+
{
241+
return (T)(object)dateTimeOffset;
242+
}
243+
}
244+
else if (typeof(T) == typeof(DateOnly))
245+
{
246+
if (DateOnly.TryParse(value, out var dateOnly))
247+
{
248+
return (T)(object)dateOnly;
249+
}
250+
}
251+
else if (typeof(T) == typeof(int))
252+
{
253+
if (int.TryParse(value, out var intValue))
254+
{
255+
return (T)(object)intValue;
256+
}
257+
}
258+
else if (typeof(T) == typeof(long))
259+
{
260+
if (long.TryParse(value, out var longValue))
261+
{
262+
return (T)(object)longValue;
263+
}
264+
}
265+
else if (typeof(T) == typeof(bool))
266+
{
267+
if (bool.TryParse(value, out var boolValue))
268+
{
269+
return (T)(object)boolValue;
270+
}
271+
}
272+
else if (typeof(T) == typeof(double))
273+
{
274+
if (double.TryParse(value, out var doubleValue))
275+
{
276+
return (T)(object)doubleValue;
277+
}
278+
}
279+
}
280+
catch
281+
{
282+
// Return null on any conversion failure
283+
}
284+
285+
return null;
286+
}
287+
288+
private static string ConvertToString<T>(T value) where T : struct
289+
{
290+
if (typeof(T) == typeof(DateTimeOffset))
291+
{
292+
return ((DateTimeOffset)(object)value).ToString("O");
293+
}
294+
else if (typeof(T) == typeof(DateOnly))
295+
{
296+
return ((DateOnly)(object)value).ToString("yyyy-MM-dd");
297+
}
298+
299+
return value.ToString() ?? string.Empty;
300+
}
301+
}

ThingConnect.Pulse.Server/obj/Debug/net8.0/ThingConnect.Pulse.Server.AssemblyInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
1515
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright © ThingConnect")]
1616
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
17-
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+c6abc4babd345cead550af112cefb8d49d7b8e90")]
17+
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8a960969e22b060d78db78b8c40357814a606169")]
1818
[assembly: System.Reflection.AssemblyProductAttribute("ThingConnect Pulse")]
1919
[assembly: System.Reflection.AssemblyTitleAttribute("ThingConnect.Pulse.Server")]
2020
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
19d72d003ff78fb0ef36fda9d2dfbdf7debdcbde88edb0714a061ee2349d2e72
1+
db6411c501c204aa56233767458ea00081da3f7c0f7aff2c3cc201e1ad2b21b6
Binary file not shown.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"GlobalPropertiesHash":"9PK/OGCf+RzB4m3f+iTtM4wrt+FifsN+qUnFZIesk+g=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["cJoK5obhTAwfod5MYOINhtAzyuvHSObg/BC6V13Rdps=","xHnJxM/KqSX72yPdHJARz9P2KMJy0x90ysywKPc9tZQ="],"CachedAssets":{},"CachedCopyCandidates":{}}
1+
{"GlobalPropertiesHash":"9PK/OGCf+RzB4m3f+iTtM4wrt+FifsN+qUnFZIesk+g=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["C6Jjvpuerd39Yox3lGG\u002Bp6FAssMvUh7MY5AKsRRnRag=","Ex39stWnH\u002BmhdI4V5jYTrZmxOWibKu1SRPLZyJMpWTc="],"CachedAssets":{},"CachedCopyCandidates":{}}

0 commit comments

Comments
 (0)