Coverage for gws-app/gws/server/monitor.py: 33%

113 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Server monitor, which watches files and runs periodic tasks.""" 

2 

3import os 

4 

5import gws 

6import gws.lib.watcher 

7import gws.server.uwsgi_module 

8 

9from . import control 

10 

11_LOCK_FILE = '/tmp/monitor.lock' 

12_RELOAD_FILE = '/tmp/monitor.reload' 

13_RECONFIGURE_FILE = '/tmp/monitor.reconfigure' 

14_TICK_FREQUENCY = 3 

15 

16DEFAULT_FREQUENCY = 30 

17 

18 

19class _Task(gws.Data): 

20 """A registered periodic task.""" 

21 

22 obj: gws.Node 

23 """Object with a ``periodic_task`` method.""" 

24 frequency: int 

25 """Interval between runs in seconds.""" 

26 lastTime: int 

27 """Time of the last run, as a Unix timestamp.""" 

28 

29 

30class Object(gws.ServerMonitor): 

31 """Server monitor.""" 

32 

33 enabled: bool 

34 """Not used.""" 

35 frequency: int 

36 """Default interval of periodic tasks in seconds.""" 

37 watcher: gws.lib.watcher.Watcher 

38 """File watcher, created on start unless ``disableWatch`` is set.""" 

39 dirs: list 

40 """Watched directories, as tuples of directory, file pattern and recursive flag.""" 

41 files: list 

42 """Watched files.""" 

43 tasks: list[_Task] 

44 """Registered periodic tasks.""" 

45 

46 def configure(self): 

47 self.frequency = self.cfg('frequency', default=DEFAULT_FREQUENCY) 

48 self.dirs = [] 

49 self.files = [] 

50 self.tasks = [] 

51 

52 def watch_directory(self, dirname, pattern, recursive=False): 

53 self.dirs.append((dirname, pattern, recursive)) 

54 

55 def watch_file(self, path): 

56 self.files.append(path) 

57 

58 def register_periodic_task(self, obj, frequency=0): 

59 if not hasattr(obj, 'periodic_task'): 

60 raise gws.Error(f'MONITOR: {obj!r} has no periodic_task') 

61 self.tasks.append( 

62 _Task( 

63 obj=obj, 

64 frequency=frequency or self.frequency, 

65 lastTime=0, 

66 ) 

67 ) 

68 

69 def schedule_reload(self, with_reconfigure=False): 

70 gws.log.info(f'MONITOR: reload scheduled {with_reconfigure=}') 

71 self._touch(_RECONFIGURE_FILE if with_reconfigure else _RELOAD_FILE) 

72 

73 def start(self): 

74 self._check_unlink(_LOCK_FILE) 

75 self._check_unlink(_RELOAD_FILE) 

76 self._check_unlink(_RECONFIGURE_FILE) 

77 

78 for t in self.tasks: 

79 t.lastTime = gws.u.stime() 

80 

81 if not self.cfg('disableWatch'): 

82 

83 def notify(evt, path): 

84 self._touch(_RECONFIGURE_FILE) 

85 

86 self.watcher = gws.lib.watcher.new(notify) 

87 

88 for d in self.dirs: 

89 self.watcher.add_directory(*d) 

90 for f in self.files: 

91 self.watcher.add_file(f) 

92 

93 self.watcher.start() 

94 

95 uwsgi = gws.server.uwsgi_module.load() 

96 uwsgi.register_signal(42, 'worker2', self._tick) 

97 uwsgi.add_timer(42, _TICK_FREQUENCY) 

98 

99 gws.log.info(f'MONITOR: started') 

100 

101 def _tick(self, signo): 

102 """Handle the timer signal: reconfigure, reload or run the periodic tasks that are due. 

103 

104 Reconfigure and reload requests are signalled through marker files in ``/tmp``, 

105 so that they reach the monitor from any process. A lock file prevents overlapping runs. 

106 """ 

107 do_reconfigure = self._check_unlink(_RECONFIGURE_FILE) 

108 do_reload = self._check_unlink(_RELOAD_FILE) 

109 

110 tasks = [t for t in self.tasks if gws.u.stime() - t.lastTime >= t.frequency] 

111 

112 if not do_reconfigure and not do_reload and not tasks: 

113 # gws.log.debug(f'MONITOR: tick skip') 

114 return 

115 

116 gws.log.debug(f'MONITOR: tick {do_reconfigure=} {do_reload=} tasks={len(tasks)}') 

117 

118 try: 

119 self._touch(_LOCK_FILE, excl=True) 

120 except FileExistsError: 

121 gws.log.debug(f'MONITOR: locked') 

122 return 

123 

124 try: 

125 if do_reconfigure: 

126 self._reload(True) 

127 elif do_reload: 

128 self._reload(False) 

129 elif tasks: 

130 self._run_periodic_tasks(tasks) 

131 finally: 

132 self._check_unlink(_LOCK_FILE) 

133 

134 def _reload(self, with_reconfigure): 

135 """Reload the web backend, then the spool backend, which restarts the monitor. 

136 

137 If the configuration fails, nothing is reloaded. 

138 """ 

139 gws.log.info(f'MONITOR: reloading...') 

140 

141 if not self._reload2(with_reconfigure): 

142 return 

143 

144 # ok, reload ourselves 

145 gws.log.info(f'MONITOR: bye bye') 

146 control.reload_app('spool') 

147 

148 def _reload2(self, with_reconfigure): 

149 """Optionally reconfigure, then reload the web backend, return False on failure.""" 

150 if with_reconfigure: 

151 try: 

152 control.configure_and_store() 

153 except Exception as exc: 

154 gws.log.exception(f'MONITOR: configuration error: {exc!r}') 

155 return False 

156 

157 try: 

158 control.reload_app('web') 

159 return True 

160 except Exception as exc: 

161 gws.log.exception(f'MONITOR: reload error: {exc!r}') 

162 return False 

163 

164 def _run_periodic_tasks(self, tasks): 

165 """Run the given tasks, logging and skipping failed ones.""" 

166 for t in tasks: 

167 try: 

168 t.obj.periodic_task() 

169 t.lastTime = gws.u.stime() 

170 except Exception as exc: 

171 gws.log.exception(f'MONITOR: periodic task failed {t.obj}: {exc!r}') 

172 

173 def _touch(self, path, excl=False): 

174 """Create an empty file, fail if it exists and ``excl`` is set.""" 

175 flags = os.O_CREAT | os.O_WRONLY 

176 if excl: 

177 flags |= os.O_EXCL 

178 os.close(os.open(path, flags)) 

179 

180 def _check_unlink(self, path): 

181 """Delete a file, return True if it existed.""" 

182 try: 

183 os.unlink(path) 

184 return True 

185 except FileNotFoundError: 

186 return False