Skip to content

Commit 50eebd7

Browse files
committed
Windows CI exposing some problems, try to fix...
1 parent 4771ae1 commit 50eebd7

2 files changed

Lines changed: 79 additions & 93 deletions

File tree

.github/workflows/CI.yml

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ on:
66

77
env:
88
DOTNET_VERSION: '6.0.428'
9+
DOTNET_SDK_VERSION: '10.0.x'
910

1011
jobs:
1112
buildAndTestWindows:
@@ -16,9 +17,13 @@ jobs:
1617
steps:
1718
- uses: actions/checkout@v4
1819
- name: Setup .NET
19-
uses: actions/setup-dotnet@v3
20+
uses: actions/setup-dotnet@v4
2021
with:
21-
dotnet-version: ${{ env.DOTNET_VERSION }}
22+
# .NET 6 runtime for the FAKE build tool (net6.0); .NET 10 SDK for the net10.0 targets
23+
# and to satisfy global.json. Installed explicitly rather than relying on the runner image.
24+
dotnet-version: |
25+
${{ env.DOTNET_VERSION }}
26+
${{ env.DOTNET_SDK_VERSION }}
2227
- name: Build & Run Tests
2328
run: |
2429
.\build.cmd
@@ -41,9 +46,13 @@ jobs:
4146
steps:
4247
- uses: actions/checkout@v4
4348
- name: Setup .NET
44-
uses: actions/setup-dotnet@v3
49+
uses: actions/setup-dotnet@v4
4550
with:
46-
dotnet-version: ${{ env.DOTNET_VERSION }}
51+
# .NET 6 runtime for the FAKE build tool (net6.0); .NET 10 SDK for the net10.0 targets
52+
# and to satisfy global.json. Installed explicitly rather than relying on the runner image.
53+
dotnet-version: |
54+
${{ env.DOTNET_VERSION }}
55+
${{ env.DOTNET_SDK_VERSION }}
4756
- name: Build & Run Tests
4857
run: |
4958
./build.sh
@@ -66,9 +75,13 @@ jobs:
6675
steps:
6776
- uses: actions/checkout@v4
6877
- name: Setup .NET
69-
uses: actions/setup-dotnet@v3
78+
uses: actions/setup-dotnet@v4
7079
with:
71-
dotnet-version: ${{ env.DOTNET_VERSION }}
80+
# .NET 6 runtime for the FAKE build tool (net6.0); .NET 10 SDK for the net10.0 targets
81+
# and to satisfy global.json. Installed explicitly rather than relying on the runner image.
82+
dotnet-version: |
83+
${{ env.DOTNET_VERSION }}
84+
${{ env.DOTNET_SDK_VERSION }}
7285
- name: Build & Run Tests
7386
run: |
7487
./build.sh

src/SQLProvider.DesignTime/SqlDesignTime.fs

Lines changed: 60 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,19 +1289,18 @@ type SqlRuntimeInfo (config : TypeProviderConfig) =
12891289
member __.RuntimeAssembly = runtimeAssembly
12901290

12911291
/// One generation of provided types for one set of static parameters.
1292-
/// Reference equality so that ConcurrentDictionary.TryUpdate swaps compare generations,
1293-
/// not their (concurrently mutated) field values.
12941292
[<ReferenceEquality>]
12951293
type DesignCacheEntry =
1296-
{ /// The provided root type, when it was built, and how old the build may get before a
1297-
/// background refresh is started (adapted to build duration on slow systems).
1298-
Root : Lazy<ProvidedTypeDefinition * DateTime * TimeSpan>
1299-
/// Last access in UTC ticks. Updated lock-free on every instantiation request;
1300-
/// entries idle longer than the expiration are dropped to free memory.
1301-
mutable LastAccess : int64
1302-
/// 1 while a background refresh build is in flight: at most one refresh per entry,
1303-
/// no matter how many Visual Studio threads request the type concurrently.
1304-
mutable Refreshing : int }
1294+
{ /// The lazily-built provided root type, paired with the idle window this entry should survive
1295+
/// once unused (a back-pressure guard proportional to this build's eager connect cost — see
1296+
/// buildRoot). Once built the root is returned unchanged for the life of the entry: never swapped
1297+
/// for a freshly-built generation while live, because erased provided types are compared by
1298+
/// reference identity and mixing two generations within one compilation makes two
1299+
/// identical-looking types (e.g. 'CustomersEntity') fail to unify (FS0001/FS0193).
1300+
Root : Lazy<ProvidedTypeDefinition * TimeSpan>
1301+
/// Last access in UTC ticks, updated lock-free on every instantiation request. Entries idle
1302+
/// (unaccessed) for their whole idle window are dropped to free memory.
1303+
mutable LastAccess : int64 }
13051304

13061305
module DesignTimeCache =
13071306
let cache = System.Collections.Concurrent.ConcurrentDictionary<DesignCacheKey,DesignCacheEntry>()
@@ -1523,11 +1522,8 @@ type public SqlTypeProvider(config: TypeProviderConfig) as this =
15231522
args.[12] :?> string, // SSDT Path
15241523
typeName)
15251524

1526-
// Entries idle longer than this are dropped to free memory. An actively used entry is
1527-
// never dropped on a timer; instead it is refreshed in the background once its build is
1528-
// older than its staleness interval, so database schema changes are still picked up
1529-
// without IntelliSense ever stalling on a synchronous rebuild.
1530-
let idleExpiration = TimeSpan.FromMinutes 3.0
1525+
// Minimum time an unused entry is kept before it may be reclaimed.
1526+
let idleFloor = TimeSpan.FromMinutes 3.0
15311527

15321528
let buildRoot (args:DesignCacheKey) =
15331529
let struct(_,_,_,_,_,_,_,_,_,_,_,_,_, rootTypeName) = args
@@ -1540,100 +1536,77 @@ type public SqlTypeProvider(config: TypeProviderConfig) as this =
15401536
createTypes rootType serviceType readServiceType config sqlRuntimeInfo invalidate registerDispose args
15411537
createConstructors config (rootType, serviceType, readServiceType, args)
15421538

1543-
// Refresh no sooner than the idle expiration, and on a slow system no sooner than
1544-
// 10x the time a build takes. Fetching the full schema of a big database is heavy;
1545-
// the staleness window must stay well above the fetch time so a refresh always
1546-
// finishes long before the next one is due. Otherwise refreshes (each followed by a
1547-
// re-check) would overlap and build back-pressure that eventually stalls the UI.
1548-
// The window is derived from this build's own duration, so it adapts as the schema grows.
1539+
// Back-pressure guard: keep an idle entry cached for at least the floor, and longer in
1540+
// proportion to how long this build took, so we don't drop and rebuild an expensive entry
1541+
// faster than a rebuild completes (which would pile up and stall the host). Only the eager
1542+
// part of the build is timed here — creating the provider, opening the design-time
1543+
// connection and reading type mappings; the per-table schema is lazy and forced later — so
1544+
// in practice this extends retention for sources that are slow to connect (typically remote
1545+
// servers) and stays at the floor for fast/local ones. max() also guards a clock that
1546+
// jumps backwards mid-build (negative duration falls back to the floor).
15491547
let buildDuration = DateTime.UtcNow - buildStarted
1550-
let staleAfter = max idleExpiration (TimeSpan.FromTicks(buildDuration.Ticks * 10L))
1551-
rootType, DateTime.UtcNow, staleAfter
1548+
let idleWindow = max idleFloor (TimeSpan.FromTicks(buildDuration.Ticks * 5L))
1549+
rootType, idleWindow
15521550

15531551
let dropDesignTimeDcProvider (key:DesignCacheKey) =
1554-
// Release the design-time data context provider (used by the Individuals feature)
1555-
// together with its type tree generation, so it cannot pin stale schema in memory.
1552+
// Release the design-time data context provider (used by the Individuals feature) when
1553+
// its cache entry is evicted, so it cannot pin stale schema in memory.
15561554
let struct(_,_,_,_,_,_,_,_,_,_,_,_,_, rootTypeName) = key
15571555
DcCache.providerCache.TryRemove rootTypeName |> ignore
15581556

1559-
let rec idleWatcher (key:DesignCacheKey) =
1557+
let rec idleWatcher (key:DesignCacheKey) (idleWindow:TimeSpan) =
15601558
async {
1561-
do! Async.Sleep (int idleExpiration.TotalMilliseconds)
1559+
// Clamp to Int32 range: Async.Sleep takes int milliseconds, and a pathologically
1560+
// large window (only reachable via an absurd connect time) would otherwise overflow
1561+
// to a negative value and throw. On overflow we just sleep the max and re-loop.
1562+
do! Async.Sleep (int (min idleWindow.TotalMilliseconds (float System.Int32.MaxValue)))
15621563
match DesignTimeCache.cache.TryGetValue key with
15631564
| true, entry ->
15641565
let lastAccess = DateTime(System.Threading.Interlocked.Read(&entry.LastAccess), DateTimeKind.Utc)
1565-
if DateTime.UtcNow - lastAccess >= idleExpiration then
1566+
if DateTime.UtcNow - lastAccess >= idleWindow then
15661567
DesignTimeCache.cache.TryRemove key |> ignore
15671568
dropDesignTimeDcProvider key
15681569
else
1569-
do! idleWatcher key
1570+
do! idleWatcher key idleWindow
15701571
| _ -> ()
15711572
}
15721573

1574+
// Start the (fire-and-forget) idle reclaimer with a failure boundary. Async.Catch keeps any
1575+
// exception — including the OperationCanceledException raised at host shutdown — from
1576+
// surfacing as an unobserved thread-pool exception. It is only a best-effort memory
1577+
// reclaimer, so on failure it just stops and the entry stays cached (safe: costs memory,
1578+
// never correctness).
1579+
let startIdleWatcher (key:DesignCacheKey) (idleWindow:TimeSpan) =
1580+
idleWatcher key idleWindow |> Async.Catch |> Async.Ignore |> Async.Start
1581+
15731582
let addCache (key:DesignCacheKey) =
15741583
{ Root =
15751584
lazy
1576-
let generation = buildRoot key
1577-
// Only reclaim idle entries in a live host (IDE). In a batch compile
1578-
// (fsc/CI, IsInvalidationSupported=false) the process is short-lived and an
1579-
// eviction+rebuild would hand out a second generation of erased provided
1580-
// types, which then fail to unify against the first.
1581-
if config.IsInvalidationSupported then idleWatcher key |> Async.Start
1585+
let (_, idleWindow) as generation = buildRoot key
1586+
// Start reclaiming this entry once it goes idle. Eviction is last-access based,
1587+
// so it only fires when nothing is using the entry (between compiles, never
1588+
// during one) and the next use rebuilds one clean generation. There is
1589+
// deliberately no background "refresh"/swap of a live entry: that is what mixed
1590+
// two generations into a single compilation and broke Windows CI.
1591+
startIdleWatcher key idleWindow
15821592
generation
1583-
LastAccess = DateTime.UtcNow.Ticks
1584-
Refreshing = 0 }
1593+
LastAccess = DateTime.UtcNow.Ticks }
15851594

1595+
// GetOrAdd only allocates the entry (its Lazy is not forced here) and Interlocked cannot
1596+
// throw, so only the build below needs the poisoned-entry cleanup.
1597+
let entry = DesignTimeCache.cache.GetOrAdd(arguments, addCache)
1598+
System.Threading.Interlocked.Exchange(&entry.LastAccess, DateTime.UtcNow.Ticks) |> ignore
15861599
try
1587-
let entry = DesignTimeCache.cache.GetOrAdd(arguments, addCache)
1588-
System.Threading.Interlocked.Exchange(&entry.LastAccess, DateTime.UtcNow.Ticks) |> ignore
1589-
1590-
// Stale-while-revalidate: always serve the current tree; if it has gone stale,
1591-
// rebuild it once in the background and swap the entry atomically. Callers never
1592-
// wait on a refresh, and concurrent VS threads can never start a second one.
1593-
//
1594-
// Only ever swap generations in a live host (IDE, IsInvalidationSupported=true).
1595-
// In a batch compile (fsc/CI) the swap is unsafe: erased provided types have
1596-
// reference identity, so if a long compilation binds some sites to the original
1597-
// generation and later sites to the swapped-in one, the two identical-looking
1598-
// 'CustomersEntity' types fail to unify (FS0001/FS0193). Windows CI builds two
1599-
// target frameworks and so is slow enough to cross the staleness window mid-build,
1600-
// which is why it fails there while Linux/Mac and warm local builds pass.
1601-
if config.IsInvalidationSupported && entry.Root.IsValueCreated then
1602-
let _, builtAt, staleAfter = entry.Root.Value
1603-
if DateTime.UtcNow - builtAt >= staleAfter
1604-
&& System.Threading.Interlocked.CompareExchange(&entry.Refreshing, 1, 0) = 0 then
1605-
async {
1606-
try
1607-
try
1608-
// 1. Fetch/build the new generation fully in the background. Nothing
1609-
// waits on it: on-demand callers keep getting the previous tree, so
1610-
// a slow big-database schema fetch never blocks the editor.
1611-
let freshGeneration = buildRoot arguments
1612-
let freshEntry =
1613-
{ Root = Lazy<_>.CreateFromValue freshGeneration
1614-
LastAccess = DateTime.UtcNow.Ticks
1615-
Refreshing = 0 }
1616-
// 2. Only now that the new generation is ready, invalidate. This makes
1617-
// the host re-check bind straight to the finished tree instead of
1618-
// driving a fresh blocking build on demand. It fires at most once per
1619-
// staleness window (>= 10x the build time), so it cannot churn.
1620-
this.Invalidate()
1621-
// 3. Publish it. Invalidate() only schedules a later re-check, so the
1622-
// atomic swap always lands before the host re-reads the cache; the
1623-
// re-check then finds a fresh (non-stale) entry and triggers no rebuild.
1624-
if DesignTimeCache.cache.TryUpdate(arguments, freshEntry, entry) then
1625-
dropDesignTimeDcProvider arguments
1626-
with
1627-
| _ -> () // keep serving the previous generation; retried on a later access
1628-
finally
1629-
System.Threading.Interlocked.Exchange(&entry.Refreshing, 0) |> ignore
1630-
} |> Async.Start
1631-
1632-
let root, _, _ = entry.Root.Value
1633-
root
1600+
entry.Root.Value |> fst
16341601
with
1635-
| e ->
1636-
DesignTimeCache.cache.TryRemove(arguments) |> ignore
1602+
| _ ->
1603+
// The build threw and its Lazy has cached that exception. Drop the poisoned entry so a
1604+
// genuine transient failure can be retried — but only if it is still the entry we hold,
1605+
// so we never evict a newer generation another thread has since rebuilt at this key.
1606+
match DesignTimeCache.cache.TryGetValue arguments with
1607+
| true, current when System.Object.ReferenceEquals(current, entry) ->
1608+
DesignTimeCache.cache.TryRemove arguments |> ignore
1609+
| _ -> ()
16371610
reraise()
16381611
)
16391612

0 commit comments

Comments
 (0)