Implemented shared S3 audio caching for prepare and restore --include-audio
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/audio"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -173,10 +174,13 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
registerConfigInput(cfgFile.kind, cfgFile.dst, checksum, cfgFile.source)
|
||||
}
|
||||
|
||||
var audioCacheStats s3AudioMaterializationStats
|
||||
if useS3Audio {
|
||||
if err := materializeS3AudioInputs(ctx, env, m, sessionID, &inputs); err != nil {
|
||||
stats, err := materializeS3AudioInputs(ctx, env, m, sessionID, &inputs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare: materialize s3 audio: %w", err)
|
||||
}
|
||||
audioCacheStats = stats
|
||||
} else {
|
||||
if err := materializeLocalAudioInputs(env, paths, resolvedLocalAudio, registerInput); err != nil {
|
||||
return nil, fmt.Errorf("prepare: %w", err)
|
||||
@@ -211,6 +215,11 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
"inputs_count": len(inputs),
|
||||
"audio_files_resolved": countAudioInputs(inputs),
|
||||
}
|
||||
if useS3Audio {
|
||||
metadata["audio_cache_hits"] = audioCacheStats.CacheHits
|
||||
metadata["audio_cache_misses"] = audioCacheStats.CacheMisses
|
||||
metadata["audio_s3_downloads"] = audioCacheStats.Downloads
|
||||
}
|
||||
if len(previousRequirements) > 0 {
|
||||
metadata["previous_requirements_count"] = len(previousRequirements)
|
||||
if previousHydration != nil {
|
||||
@@ -345,28 +354,34 @@ func materializeLocalAudioInputs(env *Env, paths artifacts.SessionPaths, resolve
|
||||
return nil
|
||||
}
|
||||
|
||||
func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifest, sessionID string, inputs *[]manifest.InputRecord) error {
|
||||
type s3AudioMaterializationStats struct {
|
||||
CacheHits int
|
||||
CacheMisses int
|
||||
Downloads int
|
||||
}
|
||||
|
||||
func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifest, sessionID string, inputs *[]manifest.InputRecord) (s3AudioMaterializationStats, error) {
|
||||
if env.ObjectStore == nil {
|
||||
return fmt.Errorf("s3 audio input requires object store backend")
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("s3 audio input requires object store backend")
|
||||
}
|
||||
if env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil || env.Config.Pipeline.Storage.S3 == nil || env.Config.Session.Inputs.AudioS3 == nil {
|
||||
return fmt.Errorf("s3 audio input requires pipeline.storage.s3 and session.inputs.audio_s3 configuration")
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("s3 audio input requires pipeline.storage.s3 and session.inputs.audio_s3 configuration")
|
||||
}
|
||||
|
||||
campaign := strings.TrimSpace(env.Config.Session.Campaign)
|
||||
if campaign == "" {
|
||||
return fmt.Errorf("session campaign is required for s3 audio input")
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("session campaign is required for s3 audio input")
|
||||
}
|
||||
runID := strings.TrimSpace(m.RunID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("run id is required for s3 audio input")
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("run id is required for s3 audio input")
|
||||
}
|
||||
|
||||
sessionPrefix := artifacts.S3SessionPrefix(env.Config.Pipeline.Storage.S3.RootPrefix, campaign, sessionID)
|
||||
audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, env.Config.Session.Inputs.AudioS3.Prefix)
|
||||
objects, err := env.ObjectStore.List(ctx, audioPrefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list s3 audio objects under %q: %w", audioPrefix, err)
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("list s3 audio objects under %q: %w", audioPrefix, err)
|
||||
}
|
||||
|
||||
audioObjects := make([]storage.ObjectInfo, 0, len(objects))
|
||||
@@ -384,7 +399,7 @@ func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifes
|
||||
return audioObjects[i].Key < audioObjects[j].Key
|
||||
})
|
||||
if len(audioObjects) == 0 {
|
||||
return fmt.Errorf("no .flac files found under s3 audio prefix %q", audioPrefix)
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("no .flac files found under s3 audio prefix %q", audioPrefix)
|
||||
}
|
||||
|
||||
spoolAudioDir := strings.TrimSpace(m.LocalSpoolDir)
|
||||
@@ -394,45 +409,60 @@ func materializeS3AudioInputs(ctx context.Context, env *Env, m *manifest.Manifes
|
||||
workAudioDir := filepath.Join(pathsWorkDirForManifest(env, m, sessionID), "audio")
|
||||
|
||||
if err := os.MkdirAll(spoolAudioDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create spool audio directory %q: %w", spoolAudioDir, err)
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("create spool audio directory %q: %w", spoolAudioDir, err)
|
||||
}
|
||||
if err := os.MkdirAll(workAudioDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create work audio directory %q: %w", workAudioDir, err)
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("create work audio directory %q: %w", workAudioDir, err)
|
||||
}
|
||||
|
||||
seenBase := map[string]string{}
|
||||
stats := s3AudioMaterializationStats{}
|
||||
cacheEnabled := env.Config.Pipeline.Cache.S3Audio == nil || *env.Config.Pipeline.Cache.S3Audio
|
||||
for _, obj := range audioObjects {
|
||||
base := path.Base(obj.Key)
|
||||
if prev, exists := seenBase[base]; exists && prev != obj.Key {
|
||||
return fmt.Errorf("duplicate s3 audio basename %q from %q and %q", base, prev, obj.Key)
|
||||
return s3AudioMaterializationStats{}, fmt.Errorf("duplicate s3 audio basename %q from %q and %q", base, prev, obj.Key)
|
||||
}
|
||||
seenBase[base] = obj.Key
|
||||
|
||||
spoolPath := filepath.Join(spoolAudioDir, base)
|
||||
if err := env.ObjectStore.Download(ctx, obj.Key, spoolPath); err != nil {
|
||||
return fmt.Errorf("download s3 audio object %q: %w", obj.Key, err)
|
||||
}
|
||||
|
||||
workPath := filepath.Join(workAudioDir, base)
|
||||
checksum, err := copyFileIfChanged(env.ArtifactStore, spoolPath, workPath)
|
||||
result, err := audio.MaterializeS3Audio(ctx, audio.S3MaterializeRequest{
|
||||
Store: env.ObjectStore,
|
||||
Object: obj,
|
||||
Bucket: strings.TrimSpace(env.Config.Pipeline.Storage.S3.Bucket),
|
||||
CacheRoot: strings.TrimSpace(env.Config.Pipeline.Cache.Root),
|
||||
CacheEnabled: cacheEnabled,
|
||||
SpoolPath: spoolPath,
|
||||
DestPath: workPath,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("materialize downloaded audio %q: %w", base, err)
|
||||
return s3AudioMaterializationStats{}, err
|
||||
}
|
||||
if result.CacheHit {
|
||||
stats.CacheHits++
|
||||
} else {
|
||||
stats.CacheMisses++
|
||||
}
|
||||
if result.Downloaded {
|
||||
stats.Downloads++
|
||||
}
|
||||
|
||||
*inputs = append(*inputs, manifest.InputRecord{
|
||||
Kind: "audio",
|
||||
Path: workPath,
|
||||
Checksum: checksum,
|
||||
Checksum: result.Checksum,
|
||||
Source: "s3",
|
||||
S3Bucket: strings.TrimSpace(env.Config.Pipeline.Storage.S3.Bucket),
|
||||
S3Key: obj.Key,
|
||||
S3Size: obj.Size,
|
||||
S3ETag: obj.ETag,
|
||||
SpoolPath: spoolPath,
|
||||
SpoolPath: result.SpoolPath,
|
||||
CachePath: result.CachePath,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func countAudioInputs(inputs []manifest.InputRecord) int {
|
||||
|
||||
@@ -245,6 +245,15 @@ func TestPrepareStageS3AudioDownloadAndMaterialization(t *testing.T) {
|
||||
t.Fatalf("expected file %q: %v", p, err)
|
||||
}
|
||||
}
|
||||
if got := result.Metadata["audio_cache_hits"]; got != 0 {
|
||||
t.Fatalf("audio_cache_hits = %#v, want 0", got)
|
||||
}
|
||||
if got := result.Metadata["audio_cache_misses"]; got != 2 {
|
||||
t.Fatalf("audio_cache_misses = %#v, want 2", got)
|
||||
}
|
||||
if got := result.Metadata["audio_s3_downloads"]; got != 2 {
|
||||
t.Fatalf("audio_s3_downloads = %#v, want 2", got)
|
||||
}
|
||||
|
||||
audioInputs := 0
|
||||
for _, in := range m.Inputs {
|
||||
@@ -258,15 +267,75 @@ func TestPrepareStageS3AudioDownloadAndMaterialization(t *testing.T) {
|
||||
if in.S3Bucket != "my-dnd-archive" {
|
||||
t.Fatalf("audio input bucket = %q", in.S3Bucket)
|
||||
}
|
||||
if in.S3Key == "" || in.SpoolPath == "" || in.Checksum == "" {
|
||||
if in.S3Key == "" || in.SpoolPath == "" || in.CachePath == "" || in.Checksum == "" {
|
||||
t.Fatalf("audio input missing provenance: %#v", in)
|
||||
}
|
||||
if _, err := os.Stat(in.CachePath); err != nil {
|
||||
t.Fatalf("expected cache path %q: %v", in.CachePath, err)
|
||||
}
|
||||
}
|
||||
if audioInputs != 2 {
|
||||
t.Fatalf("audio input count = %d, want 2", audioInputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareStageS3AudioUsesCacheOnRerun(t *testing.T) {
|
||||
env, m := setupPrepareEnv(t)
|
||||
env.Config.Session.Campaign = "forsaken"
|
||||
env.Config.Session.Inputs.AudioDir = ""
|
||||
env.Config.Session.Inputs.AudioFiles = nil
|
||||
env.Config.Session.Inputs.AudioS3 = &config.SessionAudioS3Input{Prefix: "audio/"}
|
||||
env.Config.Pipeline.Spool = config.SpoolConfig{Root: filepath.Join(t.TempDir(), "spool")}
|
||||
env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"}
|
||||
m.RunID = "20260515T031522Z-a1b2c3d4"
|
||||
m.LocalWorkDir = artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, "forsaken", m.SessionID, m.RunID)
|
||||
m.LocalSpoolDir = artifacts.SessionSpoolAudioDir(env.Config.Pipeline.Spool.Root, "forsaken", m.SessionID, m.RunID)
|
||||
|
||||
fake := &storage.FakeBackend{}
|
||||
fake.SeedObject(storage.FakeObject{Key: "dnd/campaigns/forsaken/sessions/2026-05-03/audio/alice.flac", Data: []byte("alice")})
|
||||
env.ObjectStore = fake
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("first prepare.Run() error = %v", err)
|
||||
}
|
||||
if len(fake.Downloads) != 1 {
|
||||
t.Fatalf("downloads = %d, want 1", len(fake.Downloads))
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(filepath.Join(m.LocalWorkDir, "audio")); err != nil {
|
||||
t.Fatalf("remove work audio: %v", err)
|
||||
}
|
||||
if err := os.RemoveAll(m.LocalSpoolDir); err != nil {
|
||||
t.Fatalf("remove spool audio: %v", err)
|
||||
}
|
||||
fake.DownloadErr = os.ErrPermission
|
||||
|
||||
m2 := manifest.New("2026-05-03", time.Now().UTC())
|
||||
m2.RunID = "20260515T041522Z-a1b2c3d4"
|
||||
m2.LocalWorkDir = artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, "forsaken", m2.SessionID, m2.RunID)
|
||||
m2.LocalSpoolDir = artifacts.SessionSpoolAudioDir(env.Config.Pipeline.Spool.Root, "forsaken", m2.SessionID, m2.RunID)
|
||||
result, err := (prepareStage{}).Run(context.Background(), env, m2)
|
||||
if err != nil {
|
||||
t.Fatalf("cached prepare.Run() error = %v", err)
|
||||
}
|
||||
if len(fake.Downloads) != 1 {
|
||||
t.Fatalf("downloads = %d, want cached rerun to avoid new download", len(fake.Downloads))
|
||||
}
|
||||
if got := result.Metadata["audio_cache_hits"]; got != 1 {
|
||||
t.Fatalf("audio_cache_hits = %#v, want 1", got)
|
||||
}
|
||||
if got := result.Metadata["audio_s3_downloads"]; got != 0 {
|
||||
t.Fatalf("audio_s3_downloads = %#v, want 0", got)
|
||||
}
|
||||
audioInput := findManifestInput(t, m2.Inputs, "audio")
|
||||
if audioInput.CachePath == "" {
|
||||
t.Fatalf("audio input missing cache path: %#v", audioInput)
|
||||
}
|
||||
if audioInput.SpoolPath != "" {
|
||||
t.Fatalf("audio input spool path = %q, want empty on cache hit", audioInput.SpoolPath)
|
||||
}
|
||||
mustReadFileEquals(t, filepath.Join(m2.LocalWorkDir, "audio", "alice.flac"), "alice")
|
||||
}
|
||||
|
||||
func TestPrepareStageS3AudioFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -551,7 +620,10 @@ inputs:
|
||||
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||
|
||||
cfg := &config.Config{
|
||||
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
|
||||
Pipeline: &config.PipelineConfig{
|
||||
Workspace: config.WorkspaceConfig{Root: workspace},
|
||||
Cache: config.CacheConfig{Root: filepath.Join(t.TempDir(), "cache"), S3Audio: boolPtr(true)},
|
||||
},
|
||||
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
|
||||
SessionPath: sessionPath,
|
||||
CampaignPath: campaignPath,
|
||||
@@ -616,3 +688,14 @@ func findManifestInput(t *testing.T, inputs []manifest.InputRecord, kind string)
|
||||
t.Fatalf("manifest input kind %q not found in %#v", kind, inputs)
|
||||
return manifest.InputRecord{}
|
||||
}
|
||||
|
||||
func mustReadFileEquals(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %q: %v", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("%q = %q, want %q", path, string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user