Skip to content

Commit 9a17d90

Browse files
Merge pull request #371 from rest-sh/fix/local-spec-startup-cache
fix: speed up local spec startup
2 parents 6797472 + b34aac3 commit 9a17d90

6 files changed

Lines changed: 197 additions & 9 deletions

File tree

internal/cli/cli.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ type cliArgScan struct {
778778
ProfileName string
779779
ConfigPath string
780780
ExplicitConfigPath bool
781+
VersionFlag bool
781782
Silent bool
782783
Bootstrap bool
783784
GeneratedAPICommandTree bool
@@ -793,11 +794,16 @@ func scanCLIArgs(args []string) cliArgScan {
793794
return scan
794795
}
795796

797+
hasHelpFlag := false
796798
hasBootstrapFlag := false
797799
for i := 1; i < len(args); i++ {
798800
arg := args[i]
799801
switch arg {
800-
case "--help", "-h", "--version":
802+
case "--help", "-h":
803+
hasHelpFlag = true
804+
hasBootstrapFlag = true
805+
case "--version":
806+
scan.VersionFlag = true
801807
hasBootstrapFlag = true
802808
case "--rsh-silent", "-S":
803809
scan.Silent = true
@@ -865,6 +871,8 @@ func scanCLIArgs(args []string) cliArgScan {
865871
}
866872
if hasBootstrapFlag {
867873
scan.Bootstrap = true
874+
}
875+
if hasHelpFlag {
868876
scan.GeneratedAPICommandTree = true
869877
}
870878
return scan
@@ -1137,6 +1145,9 @@ func quietGeneratedWarningsForScan(scan cliArgScan, cfg *config.Config) bool {
11371145
}
11381146

11391147
func (c *CLI) generatedAPINamesForScan(scan cliArgScan, cfg *config.Config) []string {
1148+
if scan.VersionFlag {
1149+
return nil
1150+
}
11401151
if scan.GeneratedAPICommandTree {
11411152
if scan.FirstCommand != "" && !isBuiltinCommandName(scan.FirstCommand) {
11421153
if _, ok := cfg.APIs[scan.FirstCommand]; ok {

internal/cli/cli_internal_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,29 @@ func TestQuietGeneratedWarningsForScan(t *testing.T) {
321321
})
322322
}
323323
}
324+
325+
func TestScanVersionDoesNotRequestGeneratedAPICommandTree(t *testing.T) {
326+
cfg := &config.Config{
327+
APIs: map[string]*config.APIConfig{
328+
"myapi": {BaseURL: "https://api.example.com"},
329+
},
330+
}
331+
scan := scanCLIArgs([]string{"restish", "--version"})
332+
if !scan.Bootstrap {
333+
t.Fatal("--version should remain a bootstrap command")
334+
}
335+
if scan.GeneratedAPICommandTree {
336+
t.Fatal("--version should not load generated API command metadata")
337+
}
338+
if got := (&CLI{}).generatedAPINamesForScan(scan, cfg); len(got) != 0 {
339+
t.Fatalf("--version generated APIs = %v, want none", got)
340+
}
341+
342+
helpScan := scanCLIArgs([]string{"restish", "--help"})
343+
if !helpScan.GeneratedAPICommandTree {
344+
t.Fatal("--help should still include generated API commands")
345+
}
346+
if got := (&CLI{}).generatedAPINamesForScan(helpScan, cfg); !reflect.DeepEqual(got, []string{"myapi"}) {
347+
t.Fatalf("--help generated APIs = %v, want [myapi]", got)
348+
}
349+
}

internal/spec/cache.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ type cacheEntry struct {
3131
}
3232

3333
type cachedSpecFile struct {
34-
Source string `cbor:"source"`
35-
Local bool `cbor:"local,omitempty"`
36-
Path string `cbor:"path,omitempty"`
37-
ModTime time.Time `cbor:"mod_time,omitempty"`
38-
Size int64 `cbor:"size,omitempty"`
39-
SHA256 string `cbor:"sha256,omitempty"`
34+
Source string `cbor:"source"`
35+
Local bool `cbor:"local,omitempty"`
36+
Path string `cbor:"path,omitempty"`
37+
ModTime time.Time `cbor:"mod_time,omitempty"`
38+
ModTimeUnixNano int64 `cbor:"mod_time_unix_nano,omitempty"`
39+
Size int64 `cbor:"size,omitempty"`
40+
SHA256 string `cbor:"sha256,omitempty"`
4041
}
4142

4243
type cachedRaw struct {

internal/spec/cache_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,111 @@ func TestLoadOperationsFromCache(t *testing.T) {
389389
}
390390
}
391391

392+
func TestLoadOperationSetFromCacheAcceptsLegacyLocalSpecFileSecondPrecisionModTime(t *testing.T) {
393+
dir := t.TempDir()
394+
specPath := filepath.Join(dir, "spec.yaml")
395+
raw := []byte(`{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{"/items":{"get":{"operationId":"listItems","responses":{"200":{"description":"OK"}}}}}}`)
396+
if err := os.WriteFile(specPath, raw, 0o600); err != nil {
397+
t.Fatalf("write spec: %v", err)
398+
}
399+
mtime := time.Unix(1700000000, 987654321)
400+
if err := os.Chtimes(specPath, mtime, mtime); err != nil {
401+
t.Fatalf("chtimes: %v", err)
402+
}
403+
info, err := os.Stat(specPath)
404+
if err != nil {
405+
t.Fatalf("stat spec: %v", err)
406+
}
407+
if info.ModTime().Nanosecond() == 0 {
408+
t.Skip("filesystem does not preserve subsecond mtimes")
409+
}
410+
loaded, err := load("application/json", raw, DefaultLoaders())
411+
if err != nil {
412+
t.Fatalf("load: %v", err)
413+
}
414+
set, err := loaded.OperationSet(OperationOptions{})
415+
if err != nil {
416+
t.Fatalf("operation set: %v", err)
417+
}
418+
419+
entry := &cacheEntry{
420+
Version: "v2",
421+
FetchedAt: info.ModTime().Add(time.Minute),
422+
ExpiresAt: time.Now().Add(time.Hour),
423+
Spec: cachedRaw{
424+
ContentType: "application/json",
425+
Raw: raw,
426+
LocalPath: specPath,
427+
},
428+
SpecFiles: []cachedSpecFile{{
429+
Source: specPath,
430+
Local: true,
431+
Path: specPath,
432+
ModTime: info.ModTime().Truncate(time.Second),
433+
Size: info.Size(),
434+
}},
435+
}
436+
entry.upsertOperationSet(OperationOptions{}, set)
437+
if err := writeCache(dir, "testapi", entry); err != nil {
438+
t.Fatalf("writeCache: %v", err)
439+
}
440+
441+
got, _, ok := LoadOperationSetFromCacheStatus(dir, "testapi", "v2", []string{specPath}, OperationOptions{}, true)
442+
if !ok {
443+
t.Fatal("expected operations cache hit")
444+
}
445+
if len(got.Operations) != 1 || got.Operations[0].ID != "listItems" {
446+
t.Fatalf("unexpected operations: %#v", got.Operations)
447+
}
448+
}
449+
450+
func TestStoreSpecInCachePreservesLocalSpecFileNanosecondModTime(t *testing.T) {
451+
dir := t.TempDir()
452+
specPath := filepath.Join(dir, "spec.yaml")
453+
raw := []byte(`{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{"/items":{"get":{"operationId":"listItems","responses":{"200":{"description":"OK"}}}}}}`)
454+
if err := os.WriteFile(specPath, raw, 0o600); err != nil {
455+
t.Fatalf("write spec: %v", err)
456+
}
457+
mtime := time.Unix(1700000000, 456789123)
458+
if err := os.Chtimes(specPath, mtime, mtime); err != nil {
459+
t.Fatalf("chtimes: %v", err)
460+
}
461+
info, err := os.Stat(specPath)
462+
if err != nil {
463+
t.Fatalf("stat spec: %v", err)
464+
}
465+
if info.ModTime().Nanosecond() == 0 {
466+
t.Skip("filesystem does not preserve subsecond mtimes")
467+
}
468+
apiSpec, err := OpenAPILoader{}.Load(raw)
469+
if err != nil {
470+
t.Fatalf("load: %v", err)
471+
}
472+
473+
if err := StoreSpecInCache(dir, "testapi", "v2", apiSpec, []string{specPath}, OperationOptions{}, time.Hour); err != nil {
474+
t.Fatalf("StoreSpecInCache: %v", err)
475+
}
476+
477+
entry, ok := readCacheEntry(dir, "testapi", "v2", true)
478+
if !ok {
479+
t.Fatal("expected cache entry")
480+
}
481+
if len(entry.SpecFiles) != 1 {
482+
t.Fatalf("SpecFiles len = %d, want 1", len(entry.SpecFiles))
483+
}
484+
if got, want := entry.SpecFiles[0].ModTimeUnixNano, info.ModTime().UnixNano(); got != want {
485+
t.Fatalf("cached ModTimeUnixNano = %d, want %d", got, want)
486+
}
487+
488+
got, _, ok := LoadOperationSetFromCacheStatus(dir, "testapi", "v2", []string{specPath}, OperationOptions{}, true)
489+
if !ok {
490+
t.Fatal("expected operations cache hit")
491+
}
492+
if len(got.Operations) != 1 || got.Operations[0].ID != "listItems" {
493+
t.Fatalf("unexpected operations: %#v", got.Operations)
494+
}
495+
}
496+
392497
func TestLoadOperationSetFromCacheStatusAllowsStaleRemoteMetadata(t *testing.T) {
393498
dir := t.TempDir()
394499
raw := []byte(`{"openapi":"3.1.0","info":{"title":"Test","version":"1.0.0"},"paths":{"/items":{"get":{"operationId":"listItems","responses":{"200":{"description":"OK"}}}}}}`)

internal/spec/discover.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ func cacheSpecFileMetadata(specFiles []string) []cachedSpecFile {
282282
meta.Path = path
283283
if info, statErr := os.Stat(path); statErr == nil {
284284
meta.ModTime = info.ModTime()
285+
meta.ModTimeUnixNano = info.ModTime().UnixNano()
285286
meta.Size = info.Size()
286287
}
287288
}
@@ -305,13 +306,27 @@ func cacheSpecFileMetadataMatches(specFiles []string, cached []cachedSpecFile) b
305306
current[i].Size != cached[i].Size {
306307
return false
307308
}
308-
if current[i].Local && !current[i].ModTime.Equal(cached[i].ModTime) {
309+
if current[i].Local && !cachedSpecFileModTimeMatches(current[i], cached[i]) {
309310
return false
310311
}
311312
}
312313
return true
313314
}
314315

316+
func cachedSpecFileModTimeMatches(current, cached cachedSpecFile) bool {
317+
if current.ModTimeUnixNano != 0 && cached.ModTimeUnixNano != 0 {
318+
return current.ModTimeUnixNano == cached.ModTimeUnixNano
319+
}
320+
if current.ModTime.Equal(cached.ModTime) {
321+
return true
322+
}
323+
// Legacy cache entries stored time.Time values that could round-trip
324+
// through CBOR at whole-second precision. Keep those caches usable when the
325+
// path, size, and second-level mtime still match; specFilesChangedSince
326+
// separately rejects local files modified after the cache was written.
327+
return current.ModTime.Truncate(time.Second).Equal(cached.ModTime.Truncate(time.Second))
328+
}
329+
315330
func specFilesChangedSince(specFiles []string, fetchedAt time.Time) bool {
316331
if fetchedAt.IsZero() {
317332
return false

internal/spec/discover_test.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ func TestCacheSpecFileMetadataAvoidsContentHash(t *testing.T) {
399399
if len(meta) != 1 {
400400
t.Fatalf("metadata len = %d, want 1", len(meta))
401401
}
402-
if meta[0].Size == 0 || meta[0].ModTime.IsZero() {
402+
if meta[0].Size == 0 || meta[0].ModTime.IsZero() || meta[0].ModTimeUnixNano == 0 {
403403
t.Fatalf("expected size and mtime metadata, got %+v", meta[0])
404404
}
405405
if meta[0].SHA256 != "" {
@@ -410,6 +410,36 @@ func TestCacheSpecFileMetadataAvoidsContentHash(t *testing.T) {
410410
}
411411
}
412412

413+
func TestCacheSpecFileMetadataMatchesLegacySecondPrecisionModTime(t *testing.T) {
414+
dir := t.TempDir()
415+
specPath := filepath.Join(dir, "spec.yaml")
416+
if err := os.WriteFile(specPath, []byte("openapi: 3.1.0\ninfo: {title: Demo, version: v1}\npaths: {}\n"), 0o600); err != nil {
417+
t.Fatalf("write spec: %v", err)
418+
}
419+
mtime := time.Unix(1700000000, 123456789)
420+
if err := os.Chtimes(specPath, mtime, mtime); err != nil {
421+
t.Fatalf("chtimes: %v", err)
422+
}
423+
info, err := os.Stat(specPath)
424+
if err != nil {
425+
t.Fatalf("stat spec: %v", err)
426+
}
427+
if info.ModTime().Nanosecond() == 0 {
428+
t.Skip("filesystem does not preserve subsecond mtimes")
429+
}
430+
431+
legacy := []cachedSpecFile{{
432+
Source: specPath,
433+
Local: true,
434+
Path: specPath,
435+
ModTime: info.ModTime().Truncate(time.Second),
436+
Size: info.Size(),
437+
}}
438+
if !cacheSpecFileMetadataMatches([]string{specPath}, legacy) {
439+
t.Fatal("legacy whole-second mtime metadata should match unchanged file")
440+
}
441+
}
442+
413443
// ---- loadSpecFiles -------------------------------------------------------
414444

415445
func TestLoadSpecFiles_LocalFile(t *testing.T) {

0 commit comments

Comments
 (0)