Merge lp:~harningt/duplicity/multibackend-mirror into lp:~duplicity-team/duplicity/0.7-series

Proposed by Thomas Harning
Status: Merged
Merged at revision: 1191
Proposed branch: lp:~harningt/duplicity/multibackend-mirror
Merge into: lp:~duplicity-team/duplicity/0.7-series
Diff against target: 293 lines (+174/-17)
2 files modified
bin/duplicity.1 (+55/-7)
duplicity/backends/multibackend.py (+119/-10)
To merge this branch: bzr merge lp:~harningt/duplicity/multibackend-mirror
Reviewer Review Type Date Requested Status
edso Approve
Review via email: mp+286090@code.launchpad.net

Description of the change

This changeset addresses multibackend handling to permit a mirroring option in addition to its "stripe" mode to make it a redundancy tool vs space-expansion tool. To do this without changing the configuration too much, I used the query string that would generally go unused for files to specify behavior that applies to all items inside the configuration file.

Testing would include testing that multibackend behaves exactly as it did before the change when no parameters are introduced and testing each of the (stripe / mirror) x (continue / abort) options.

Locally tested on 2 machine since around July 2015 were the mode=mirror onfail=abort modes to replace some shell-scripts I used to run after backup to synchronize a local backup with many backends.

To post a comment you must log in.
Revision history for this message
edso (ed.so) wrote :

looks good to me.. ede

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bin/duplicity.1'
2--- bin/duplicity.1 2016-02-05 09:58:57 +0000
3+++ bin/duplicity.1 2016-02-15 17:52:54 +0000
4@@ -1644,13 +1644,61 @@
5 more than one backend store (e.g., you can store across a google drive
6 account and a onedrive account to get effectively the combined storage
7 available in both). The URL path specifies a JSON formated config
8-file containing a list of the backends it will use. Multibackend then
9-round-robins across the given backends. Each element of the list must
10-have a "url" element, and may also contain an optional "description"
11-and an optional "env" list of environment variables used to configure
12-that backend.
13-.PP
14-For example:
15+file containing a list of the backends it will use. The URL may also
16+specify "query" parameters to configure overall behavior.
17+Each element of the list must have a "url" element, and may also contain
18+an optional "description" and an optional "env" list of environment
19+variables used to configure that backend.
20+
21+.SS Query Parameters
22+
23+Query parameters come after the file URL in standard HTTP format
24+for example:
25+
26+.nf
27+.RS
28+multi:///path/to/config.json?mode=mirror&onfail=abort
29+multi:///path/to/config.json?mode=stripe&onfail=continue
30+multi:///path/to/config.json?onfail=abort&mode=stripe
31+multi:///path/to/config.json?onfail=abort
32+.RE
33+
34+Order does not matter, however unrecognized parameters are considered
35+an error.
36+
37+.TP
38+.BI "mode=" stripe
39+
40+This mode (the default) performs round-robin access to the list of
41+backends. In this mode, all backends must be reliable as a loss of one
42+means a loss of one of the archive files.
43+
44+.TP
45+.BI "mode=" mirror
46+
47+This mode accesses backends as a RAID1-store, storing every file in
48+every backend and reading files from the first-successful backend.
49+A loss of any backend should result in no failure. Note that backends
50+added later will only get new files and may require a manual sync
51+with one of the other operating ones.
52+
53+.TP
54+.BI "onfail=" continue
55+
56+This setting (the default) continues all write operations in as
57+best-effort. Any failure results in the next backend tried. Failure
58+is reported only when all backends fail a given operation with the
59+error result from the last failure.
60+
61+.TP
62+.BI "onfail=" abort
63+
64+This setting considers any backend write failure as a terminating
65+condition and reports the error.
66+Data reading and listing operations are independent of this and
67+will try with the next backend on failure.
68+
69+.SS JSON File Example
70 .nf
71 .RS
72 [
73
74=== modified file 'duplicity/backends/multibackend.py'
75--- duplicity/backends/multibackend.py 2015-12-04 11:34:25 +0000
76+++ duplicity/backends/multibackend.py 2016-02-15 17:52:54 +0000
77@@ -1,6 +1,9 @@
78 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
79 #
80 # Copyright 2015 Steve Tynor <steve.tynor@gmail.com>
81+# Copyright 2016 Thomas Harning Jr <harningt@gmail.com>
82+# - mirror/stripe modes
83+# - write error modes
84 #
85 # This file is part of duplicity.
86 #
87@@ -24,6 +27,7 @@
88 import os.path
89 import string
90 import urllib
91+import urlparse
92 import json
93
94 import duplicity.backend
95@@ -37,13 +41,68 @@
96 # the stores we are managing
97 __stores = []
98
99- # when we write, we "stripe" via a simple round-robin across
100+ # Set of known query paramaters
101+ __knownQueryParameters = frozenset([
102+ 'mode',
103+ 'onfail',
104+ ])
105+
106+ # the mode of operation to follow
107+ # can be one of 'stripe' or 'mirror' currently
108+ __mode = 'stripe'
109+ __mode_allowedSet = frozenset([
110+ 'mirror',
111+ 'stripe',
112+ ])
113+
114+ # the write error handling logic
115+ # can be one of the following:
116+ # * continue - default, on failure continues to next source
117+ # * abort - stop all further operations
118+ __onfail_mode = 'continue'
119+ __onfail_mode_allowedSet = frozenset([
120+ 'abort',
121+ 'continue',
122+ ])
123+
124+ # when we write in stripe mode, we "stripe" via a simple round-robin across
125 # remote stores. It's hard to get too much more sophisticated
126 # since we can't rely on the backend to give us any useful meta
127 # data (e.g. sizes of files, capacity of the store (quotas)) to do
128 # a better job of balancing load across stores.
129 __write_cursor = 0
130
131+ @staticmethod
132+ def get_query_params(parsed_url):
133+ # Reparse so the query string is available
134+ reparsed_url = urlparse.urlparse(parsed_url.geturl())
135+ if len(reparsed_url.query) == 0:
136+ return dict()
137+ try:
138+ queryMultiDict = urlparse.parse_qs(reparsed_url.query, strict_parsing = True)
139+ except ValueError as e:
140+ log.Log(_("MultiBackend: Could not parse query string %s: %s ")
141+ % (reparsed_url.query, e),
142+ log.ERROR)
143+ raise BackendException('Could not parse query string')
144+ queryDict = dict()
145+ # Convert the multi-dict to a single dictionary
146+ # while checking to make sure that no unrecognized values are found
147+ for name, valueList in queryMultiDict.items():
148+ if len(valueList) != 1:
149+ log.Log(_("MultiBackend: Invalid query string %s: more than one value for %s")
150+ % (reparsed_url.query, name),
151+ log.ERROR)
152+ raise BackendException('Invalid query string')
153+ if name not in MultiBackend.__knownQueryParameters:
154+ log.Log(_("MultiBackend: Invalid query string %s: unknown parameter %s")
155+ % (reparsed_url.query, name),
156+ log.ERROR)
157+ raise BackendException('Invalid query string')
158+
159+ queryDict[name] = valueList[0]
160+ return queryDict
161+
162 def __init__(self, parsed_url):
163 duplicity.backend.Backend.__init__(self, parsed_url)
164
165@@ -77,10 +136,32 @@
166 # }
167 # ]
168
169+ queryParams = MultiBackend.get_query_params(parsed_url)
170+
171+ if 'mode' in queryParams:
172+ self.__mode = queryParams['mode']
173+
174+ if 'onfail' in queryParams:
175+ self.__onfail_mode = queryParams['onfail']
176+
177+ if not self.__mode in MultiBackend.__mode_allowedSet:
178+ log.Log(_("MultiBackend: illegal value for %s: %s")
179+ % ('mode', self.__mode), log.ERROR)
180+ raise BackendException("MultiBackend: invalid mode value")
181+
182+ if not self.__onfail_mode in MultiBackend.__onfail_mode_allowedSet:
183+ log.Log(_("MultiBackend: illegal value for %s: %s")
184+ % ('onfail', self.__onfail_mode), log.ERROR)
185+ raise BackendException("MultiBackend: invalid onfail value")
186+
187 try:
188 with open(parsed_url.path) as f:
189 configs = json.load(f)
190 except IOError as e:
191+ log.Log(_("MultiBackend: Url %s")
192+ % (parsed_url.geturl()),
193+ log.ERROR)
194+
195 log.Log(_("MultiBackend: Could not load config file %s: %s ")
196 % (parsed_url.path, e),
197 log.ERROR)
198@@ -88,6 +169,8 @@
199
200 for config in configs:
201 url = config['url']
202+ # Fix advised in bug #1471795
203+ url = url.encode('utf-8')
204 log.Log(_("MultiBackend: use store %s")
205 % (url),
206 log.INFO)
207@@ -106,6 +189,12 @@
208 # log.INFO)
209
210 def _put(self, source_path, remote_filename):
211+ # Store an indication of whether any of these passed
212+ passed = False
213+ # Mirror mode always starts at zero
214+ if self.__mode == 'mirror':
215+ self.__write_cursor = 0
216+
217 first = self.__write_cursor
218 while True:
219 store = self.__stores[self.__write_cursor]
220@@ -117,15 +206,29 @@
221 % (self.__write_cursor, store.backend.parsed_url.url_string),
222 log.DEBUG)
223 store.put(source_path, remote_filename)
224+ passed = True
225 self.__write_cursor = next
226- break
227+ # No matter what, if we loop around, break this loop
228+ if next == 0:
229+ break
230+ # If in stripe mode, don't continue to the next
231+ if self.__mode == 'stripe':
232+ break
233 except Exception as e:
234 log.Log(_("MultiBackend: failed to write to store #%s (%s), try #%s, Exception: %s")
235 % (self.__write_cursor, store.backend.parsed_url.url_string, next, e),
236 log.INFO)
237 self.__write_cursor = next
238
239- if (self.__write_cursor == first):
240+ # If we consider write failure as abort, abort
241+ if self.__onfail_mode == 'abort':
242+ log.Log(_("MultiBackend: failed to write %s. Aborting process.")
243+ % (source_path),
244+ log.ERROR)
245+ raise BackendException("failed to write")
246+
247+ # If we've looped around, and none of them passed, fail
248+ if (self.__write_cursor == first) and not passed:
249 log.Log(_("MultiBackend: failed to write %s. Tried all backing stores and none succeeded")
250 % (source_path),
251 log.ERROR)
252@@ -159,14 +262,16 @@
253 % (s.backend.parsed_url.url_string, l),
254 log.DEBUG)
255 lists.append(s.list())
256- # combine the lists into a single flat list:
257- result = [item for sublist in lists for item in sublist]
258+ # combine the lists into a single flat list w/o duplicates via set:
259+ result = list({ item for sublist in lists for item in sublist })
260 log.Log(_("MultiBackend: combined list: %s")
261 % (result),
262 log.DEBUG)
263 return result
264
265 def _delete(self, filename):
266+ # Store an indication on whether any passed
267+ passed = False
268 # since the backend operations will be retried, we can't
269 # simply try to get from the store, if not found, move to the
270 # next store (since each failure will be retried n times
271@@ -177,13 +282,17 @@
272 list = s.list()
273 if filename in list:
274 s._do_delete(filename)
275- return
276+ passed = True
277+ # In stripe mode, only one item will have the file
278+ if self.__mode == 'stripe':
279+ return
280 log.Log(_("MultiBackend: failed to delete %s from %s")
281 % (filename, s.backend.parsed_url.url_string),
282 log.INFO)
283- log.Log(_("MultiBackend: failed to delete %s. Tried all backing stores and none succeeded")
284- % (filename),
285- log.ERROR)
286-# raise BackendException("failed to delete")
287+ if not passed:
288+ log.Log(_("MultiBackend: failed to delete %s. Tried all backing stores and none succeeded")
289+ % (filename),
290+ log.ERROR)
291+# raise BackendException("failed to delete")
292
293 duplicity.backend.register_backend('multi', MultiBackend)

Subscribers

People subscribed via source and target branches