Flow Studio
Pinned Data Storage
Pinned data is stored in the Process_NodePinnedData table. The primary key is (TenantId, ProcessId, NodeId) — exactly one pinned data record exists per node per workflow. Each successful write overwrites the previous value.
Table Schema
-- Process_NodePinnedData
CREATE TABLE Process_NodePinnedData (
Id BIGINT IDENTITY PRIMARY KEY,
TenantId NVARCHAR(64) NOT NULL,
ProcessId INT NOT NULL,
NodeId NVARCHAR(256) NOT NULL,
DataJson NVARCHAR(MAX) NOT NULL, -- JSON-serialized pinnedData object
CreatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSUTCDATETIME(),
ExpiresAt DATETIMEOFFSET NULL, -- NULL = never expires
CONSTRAINT UQ_NodePinnedData UNIQUE (TenantId, ProcessId, NodeId)
);
PinnedDataService — Save and Upsert
// PinnedDataService.cs
public async Task SaveAsync(
int processId,
string nodeId,
object data,
CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(data);
var tenantId = _tenantContext.TenantId;
// Upsert — INSERT if first time, UPDATE if already exists
await _db.ExecuteAsync(@"
MERGE Process_NodePinnedData AS target
USING (SELECT @TenantId, @ProcessId, @NodeId) AS source (TenantId, ProcessId, NodeId)
ON target.TenantId = source.TenantId
AND target.ProcessId = source.ProcessId
AND target.NodeId = source.NodeId
WHEN MATCHED THEN
UPDATE SET DataJson = @Json, UpdatedAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN
INSERT (TenantId, ProcessId, NodeId, DataJson, CreatedAt, UpdatedAt)
VALUES (@TenantId, @ProcessId, @NodeId, @Json, SYSUTCDATETIME(), SYSUTCDATETIME());",
new { TenantId = tenantId, ProcessId = processId, NodeId = nodeId, Json = json },
cancellationToken: ct);
}
Key Semantics
| Behaviour | Detail |
|---|---|
| Scope | Per (TenantId, ProcessId, NodeId) — shared across ALL executions of the same workflow node |
| Overwrite | Each write replaces the entire DataJson column — it is not a merge or append. Read-modify-write is the caller's responsibility. |
| First write | INSERTs a new row with CreatedAt = UpdatedAt = now |
| Subsequent writes | UPDATEs only DataJson and UpdatedAt |
| Null pinnedData | No database call at all — existing row is not touched |
| Expiry | ExpiresAt is NULL by default. Set a TTL via the optional overload of SaveAsync. |
Read Path
// PinnedDataService.cs
public async Task<object?> GetAsync(
int processId,
string nodeId,
CancellationToken ct = default)
{
var tenantId = _tenantContext.TenantId;
var json = await _db.QuerySingleOrDefaultAsync<string>(@"
SELECT DataJson
FROM Process_NodePinnedData
WHERE TenantId = @TenantId
AND ProcessId = @ProcessId
AND NodeId = @NodeId
AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME())",
new { TenantId = tenantId, ProcessId = processId, NodeId = nodeId },
cancellationToken: ct);
return json is null ? null : JsonSerializer.Deserialize<object>(json);
}
One row per node — overwrite is intentional. Pinned data is designed to hold the LATEST state of a node, not a history of states. If you need historical records across runs, use a separate persistence mechanism (e.g., write to a database via a DataNode, or log the data for Loki ingestion).
ExpiresAt filtering happens in the query. Expired rows are not proactively deleted — they are silently excluded from reads. A background cleanup job (configurable interval, default 24h) removes expired rows to keep the table lean.