• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

decentraland / profile-images / 24193431867

09 Apr 2026 01:41PM UTC coverage: 88.345% (-0.8%) from 89.127%
24193431867

Pull #147

github

kevinszuchet
fix: add guards for missing processing results and avatar hashes

- Replace non-null assertion on resultByEntity.get(id) with a guard
  that logs an error and returns a retry-able failure if Godot returns
  fewer results than expected
- Log a warning when incomingHashes.get() returns undefined so missing
  change-detection metadata is observable
Pull Request #147: fix: skip Godot render when avatar has not changed

134 of 175 branches covered (76.57%)

Branch coverage included in aggregate %.

52 of 55 new or added lines in 3 files covered. (94.55%)

1 existing line in 1 file now uncovered.

480 of 520 relevant lines covered (92.31%)

9.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.42
/src/logic/image-processor.ts
1
import { Entity } from '@dcl/schemas'
2
import { AppComponents, ExtendedAvatar } from '../types'
3
import { computeAvatarHash } from '../utils/avatar-comparison'
3✔
4

5
export type ProcessingResult = ExtendedAvatar & {
6
  success: boolean
7
  shouldRetry: boolean
8
  error?: string
9
}
10

11
export type ImageProcessor = {
12
  processEntities(entities: Entity[]): Promise<ProcessingResult[]>
13
}
14

15
export async function createImageProcessor({
3✔
16
  config,
17
  logs,
18
  godot,
19
  storage,
20
  metrics
21
}: Pick<AppComponents, 'config' | 'logs' | 'godot' | 'storage' | 'metrics'>): Promise<ImageProcessor> {
22
  const logger = logs.getLogger('image-processor')
34✔
23
  const [commitHash, version] = await Promise.all([
34✔
24
    config.requireString('COMMIT_HASH'),
25
    config.requireString('CURRENT_VERSION')
26
  ])
27

28
  async function processEntities(entities: Entity[]): Promise<ProcessingResult[]> {
29
    if (entities.length === 0) {
32✔
30
      logger.warn('No entities provided to process')
1✔
31
      return []
1✔
32
    }
33

34
    const deploymentTimestamps = new Map<string, number>(entities.map(({ id, timestamp }) => [id, timestamp]))
43✔
35

36
    const avatars: ExtendedAvatar[] = entities.map(({ id, metadata }) => ({
43✔
37
      entity: id,
38
      avatar: metadata.avatars[0].avatar
39
    }))
40

41
    // Compute hashes for all incoming avatars and fetch stored hashes in parallel
42
    const incomingHashes = new Map(avatars.map((a) => [a.entity, computeAvatarHash(a.avatar)]))
43✔
43

44
    const storedHashes = await Promise.all(
31✔
45
      avatars.map(async ({ entity }) => ({
43✔
46
        entity,
47
        hash: await storage.retrieveAvatarHash(entity)
48
      }))
49
    )
50

51
    const storedByEntity = new Map(storedHashes.map(({ entity, hash }) => [entity, hash]))
43✔
52

53
    // Partition entities: those that need rendering vs those that can be skipped
54
    const needsRender: ExtendedAvatar[] = []
31✔
55
    const skipped: ExtendedAvatar[] = []
31✔
56

57
    for (const extAvatar of avatars) {
31✔
58
      const storedHash = storedByEntity.get(extAvatar.entity)
43✔
59
      const incomingHash = incomingHashes.get(extAvatar.entity)
43✔
60
      if (storedHash && storedHash === incomingHash) {
43✔
61
        skipped.push(extAvatar)
8✔
62
      } else {
63
        needsRender.push(extAvatar)
35✔
64
      }
65
    }
66

67
    // Build synthetic success results for skipped entities
68
    const skippedResults: ProcessingResult[] = skipped.map((extAvatar) => {
31✔
69
      metrics.increment('snapshot_generation_count', { status: 'skipped' }, 1)
8✔
70
      logger.debug(`Skipping image generation for entity=${extAvatar.entity}: avatar unchanged`)
8✔
71
      return {
8✔
72
        entity: extAvatar.entity,
73
        success: true,
74
        shouldRetry: false,
75
        avatar: extAvatar.avatar
76
      }
77
    })
78

79
    // If no entities need rendering, return early
80
    if (needsRender.length === 0) {
31✔
81
      return skippedResults
4✔
82
    }
83

84
    const { avatars: results, output: outputGenerated } = await godot.generateImages(needsRender)
27✔
85

86
    const renderedResults = await Promise.all(
27✔
87
      results.map(async (result) => {
88
        if (result.success) {
35✔
89
          metrics.increment('snapshot_generation_count', { status: 'success' }, 1)
24✔
90
          const hash = incomingHashes.get(result.entity)
24✔
91
          if (!hash) {
24!
NEW
92
            logger.warn(
×
93
              `No precomputed avatar hash for entity=${result.entity} — image will be stored without change-detection metadata`
94
            )
95
          }
96
          const success = await storage.storeImages(result.entity, result.avatarPath, result.facePath, hash)
24✔
97

98
          if (!success) {
24✔
99
            logger.error(`Error saving generated images to s3 for entity=${result.entity}`)
4✔
100
            return {
4✔
101
              entity: result.entity,
102
              success: false,
103
              shouldRetry: true,
104
              error: 'Failed to store images',
105
              avatar: result.avatar
106
            }
107
          }
108

109
          const deploymentTimestamp = deploymentTimestamps.get(result.entity)
20✔
110

111
          if (deploymentTimestamp) {
20✔
112
            const durationInSeconds = (Date.now() - deploymentTimestamp) / 1000
20✔
113
            if (durationInSeconds > 0) {
20✔
114
              metrics.observe('entity_deployment_to_image_generation_duration_seconds', {}, durationInSeconds)
19✔
115
              logger.debug(`Total duration for entity=${result.entity} is ${durationInSeconds}s`)
19✔
116
            }
117
          }
118

119
          return {
20✔
120
            entity: result.entity,
121
            success: true,
122
            shouldRetry: false,
123
            avatar: result.avatar
124
          }
125
        }
126

127
        metrics.increment('snapshot_generation_count', { status: 'failure' }, 1)
11✔
128

129
        if (needsRender.length === 1) {
11✔
130
          logger.debug(`Giving up on entity=${result.entity} because of godot failure.`)
4✔
131
          const failure = {
4✔
132
            timestamp: new Date().toISOString(),
133
            commitHash,
134
            version,
135
            entity: result.entity,
136
            outputGenerated
137
          }
138
          await storage.storeFailure(result.entity, JSON.stringify(failure))
4✔
139

140
          return {
4✔
141
            entity: result.entity,
142
            success: false,
143
            shouldRetry: false,
144
            error: 'Godot generation failed',
145
            avatar: result.avatar
146
          }
147
        }
148

149
        logger.debug(`Godot failure, enqueue for individual retry, entity=${result.entity}`)
7✔
150
        return {
7✔
151
          entity: result.entity,
152
          success: false,
153
          shouldRetry: true,
154
          error: 'Godot generation failed',
155
          avatar: result.avatar
156
        }
157
      })
158
    )
159

160
    // Merge rendered results with skipped results, preserving original entity order
161
    const resultByEntity = new Map<string, ProcessingResult>()
27✔
162
    for (const r of [...renderedResults, ...skippedResults]) {
27✔
163
      resultByEntity.set(r.entity, r)
39✔
164
    }
165

166
    return entities.map(({ id, metadata }) => {
27✔
167
      const result = resultByEntity.get(id)
39✔
168
      if (!result) {
39!
NEW
169
        logger.error(`No processing result for entity=${id} — Godot returned fewer results than expected`)
×
NEW
170
        return {
×
171
          entity: id,
172
          success: false,
173
          shouldRetry: true,
174
          error: 'Missing processing result',
175
          avatar: metadata.avatars[0].avatar
176
        }
177
      }
178
      return result
39✔
179
    })
180
  }
181

182
  return {
34✔
183
    processEntities
184
  }
185
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc