Coverage for ivatar/ivataraccount/migrations/0025_access_stat_tables.py: 32%

56 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 11:51 +0000

1""" 

2Migration: Move access_count from parent tables to dedicated stat tables. 

3 

4Creates PhotoAccessStat, ConfirmedEmailAccessStat, and ConfirmedOpenIdAccessStat, 

5copies existing access_count data from the parent tables, then removes the 

6access_count columns and their indexes from the parent tables. 

7 

8This improves PostgreSQL HOT (Heap Only Tuple) update rates and reduces dead 

9tuple accumulation on the wide parent tables. 

10""" 

11 

12from django.db import migrations, models 

13import django.db.models.deletion 

14 

15 

16def _flush_cache_key_to_db(model_class, model_name, pk): 

17 """ 

18 Drain any cache-buffered access count for one object into its legacy DB column. 

19 

20 Mirrors AccessStatsManager._flush_key_sync exactly — same key format, same 

21 distributed lock pattern — so it safely co-exists with any web worker that 

22 may be running a concurrent flush at migration time. Writing to the old DB 

23 column here (before RemoveField) is intentional: copy_access_counts reads 

24 those columns immediately afterwards to populate the stat tables. 

25 """ 

26 from django.core.cache import cache 

27 from django.db.models import F 

28 

29 key = f"stats:access_count:{model_name}:{pk}" 

30 lock_key = f"{key}:lock" 

31 

32 # Acquire the same distributed lock used by the web workers. 

33 # If we cannot acquire it, a worker is mid-flush; that flush will land on the 

34 # old column while it still exists, so copy_access_counts will capture it. 

35 if not cache.add(lock_key, "locked", 60): 

36 return 

37 

38 try: 

39 cached = cache.get(key) 

40 if not cached: 

41 return 

42 try: 

43 count = int(cached) 

44 except (ValueError, TypeError): 

45 return 

46 if count <= 0: 

47 return 

48 

49 model_class.objects.filter(pk=pk).update( 

50 access_count=F("access_count") + count 

51 ) 

52 try: 

53 cache.decr(key, count) 

54 except ValueError: 

55 cache.set(key, 0) 

56 cache.delete(f"stats:start_time:{model_name}:{pk}") 

57 except Exception: 

58 pass 

59 finally: 

60 cache.delete(lock_key) 

61 

62 

63def copy_access_counts(apps, schema_editor): 

64 """ 

65 Step 1 — drain any cache-buffered counts to the old parent-table columns so 

66 that no pending increments are lost when those columns are dropped. 

67 Step 2 — bulk-copy the now-complete DB values into the new stat tables. 

68 

69 Both steps run inside the migration transaction, so the copy always sees the 

70 flushed values. Any count that was being flushed concurrently by a web worker 

71 is protected by the distributed lock in _flush_cache_key_to_db; if the lock is 

72 held, the worker's own flush will complete to the old column (which still exists 

73 at this point in the migration) and the subsequent SELECT in Step 2 will capture 

74 it under READ COMMITTED semantics. 

75 """ 

76 Photo = apps.get_model("ivataraccount", "Photo") 

77 ConfirmedEmail = apps.get_model("ivataraccount", "ConfirmedEmail") 

78 ConfirmedOpenId = apps.get_model("ivataraccount", "ConfirmedOpenId") 

79 PhotoAccessStat = apps.get_model("ivataraccount", "PhotoAccessStat") 

80 ConfirmedEmailAccessStat = apps.get_model("ivataraccount", "ConfirmedEmailAccessStat") 

81 ConfirmedOpenIdAccessStat = apps.get_model("ivataraccount", "ConfirmedOpenIdAccessStat") 

82 

83 for model_class, model_name, StatClass, fk_field in ( 

84 (Photo, "Photo", PhotoAccessStat, "photo_id"), 

85 (ConfirmedEmail, "ConfirmedEmail", ConfirmedEmailAccessStat, "email_id"), 

86 (ConfirmedOpenId, "ConfirmedOpenId", ConfirmedOpenIdAccessStat, "openid_id"), 

87 ): 

88 pks = list(model_class.objects.values_list("pk", flat=True)) 

89 

90 # Step 1: drain pending cache counts into the old DB column. 

91 for pk in pks: 

92 _flush_cache_key_to_db(model_class, model_name, pk) 

93 

94 # Step 2: copy the complete DB values (post-flush) to the stat table. 

95 StatClass.objects.bulk_create( 

96 [ 

97 StatClass(**{fk_field: obj.pk, "access_count": obj.access_count}) 

98 for obj in model_class.objects.only("pk", "access_count") 

99 ], 

100 ignore_conflicts=True, 

101 ) 

102 

103 

104def reverse_copy_access_counts(apps, schema_editor): 

105 """ 

106 Reverse migration: copy access_count values back to the parent tables. 

107 """ 

108 Photo = apps.get_model("ivataraccount", "Photo") 

109 ConfirmedEmail = apps.get_model("ivataraccount", "ConfirmedEmail") 

110 ConfirmedOpenId = apps.get_model("ivataraccount", "ConfirmedOpenId") 

111 PhotoAccessStat = apps.get_model("ivataraccount", "PhotoAccessStat") 

112 ConfirmedEmailAccessStat = apps.get_model("ivataraccount", "ConfirmedEmailAccessStat") 

113 ConfirmedOpenIdAccessStat = apps.get_model("ivataraccount", "ConfirmedOpenIdAccessStat") 

114 

115 for stat in PhotoAccessStat.objects.all(): 

116 Photo.objects.filter(pk=stat.photo_id).update(access_count=stat.access_count) 

117 

118 for stat in ConfirmedEmailAccessStat.objects.all(): 

119 ConfirmedEmail.objects.filter(pk=stat.email_id).update(access_count=stat.access_count) 

120 

121 for stat in ConfirmedOpenIdAccessStat.objects.all(): 

122 ConfirmedOpenId.objects.filter(pk=stat.openid_id).update(access_count=stat.access_count) 

123 

124 

125class Migration(migrations.Migration): 

126 

127 dependencies = [ 

128 ("ivataraccount", "0024_merge_20260722_1534"), 

129 ] 

130 

131 operations = [ 

132 # 1. Create the three new stat tables 

133 migrations.CreateModel( 

134 name="PhotoAccessStat", 

135 fields=[ 

136 ( 

137 "photo", 

138 models.OneToOneField( 

139 on_delete=django.db.models.deletion.CASCADE, 

140 primary_key=True, 

141 related_name="stat", 

142 serialize=False, 

143 to="ivataraccount.photo", 

144 ), 

145 ), 

146 ("access_count", models.BigIntegerField(default=0, editable=False)), 

147 ], 

148 options={ 

149 "verbose_name": "photo access stat", 

150 "verbose_name_plural": "photo access stats", 

151 }, 

152 ), 

153 migrations.CreateModel( 

154 name="ConfirmedEmailAccessStat", 

155 fields=[ 

156 ( 

157 "email", 

158 models.OneToOneField( 

159 on_delete=django.db.models.deletion.CASCADE, 

160 primary_key=True, 

161 related_name="stat", 

162 serialize=False, 

163 to="ivataraccount.confirmedemail", 

164 ), 

165 ), 

166 ("access_count", models.BigIntegerField(default=0, editable=False)), 

167 ], 

168 options={ 

169 "verbose_name": "confirmed email access stat", 

170 "verbose_name_plural": "confirmed email access stats", 

171 }, 

172 ), 

173 migrations.CreateModel( 

174 name="ConfirmedOpenIdAccessStat", 

175 fields=[ 

176 ( 

177 "openid", 

178 models.OneToOneField( 

179 on_delete=django.db.models.deletion.CASCADE, 

180 primary_key=True, 

181 related_name="stat", 

182 serialize=False, 

183 to="ivataraccount.confirmedopenid", 

184 ), 

185 ), 

186 ("access_count", models.BigIntegerField(default=0, editable=False)), 

187 ], 

188 options={ 

189 "verbose_name": "confirmed openid access stat", 

190 "verbose_name_plural": "confirmed openid access stats", 

191 }, 

192 ), 

193 # 2. Copy existing access_count data from parent tables -> stat tables 

194 migrations.RunPython( 

195 copy_access_counts, 

196 reverse_code=reverse_copy_access_counts, 

197 ), 

198 # 3. Drop access_count indexes via RunSQL. 

199 # These were created via RunPython in migration 0021, so Django's migration 

200 # state does not track them as named indexes — RemoveIndex would fail with 

201 # "index not found in state". Same pattern used in migration 0023 for digest 

202 # indexes. IF EXISTS makes this safe to run on databases where the indexes 

203 # were never created (e.g. SQLite test databases). 

204 migrations.RunSQL( 

205 "DROP INDEX IF EXISTS idx_cemail_access_count;", 

206 reverse_sql=migrations.RunSQL.noop, 

207 ), 

208 migrations.RunSQL( 

209 "DROP INDEX IF EXISTS idx_cemail_user_access;", 

210 reverse_sql=migrations.RunSQL.noop, 

211 ), 

212 migrations.RunSQL( 

213 "DROP INDEX IF EXISTS idx_cemail_photo_access;", 

214 reverse_sql=migrations.RunSQL.noop, 

215 ), 

216 migrations.RunSQL( 

217 "DROP INDEX IF EXISTS idx_photo_access_count;", 

218 reverse_sql=migrations.RunSQL.noop, 

219 ), 

220 # 4. Remove access_count columns from parent tables 

221 migrations.RemoveField( 

222 model_name="photo", 

223 name="access_count", 

224 ), 

225 migrations.RemoveField( 

226 model_name="confirmedemail", 

227 name="access_count", 

228 ), 

229 migrations.RemoveField( 

230 model_name="confirmedopenid", 

231 name="access_count", 

232 ), 

233 ]