@@ -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>]
12951293type 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
13061305module 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 * 10 L ))
1551- rootType, DateTime.UtcNow , staleAfter
1548+ let idleWindow = max idleFloor ( TimeSpan.FromTicks( buildDuration.Ticks * 5 L ))
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