1 {-| Module describing a node.
3 All updates are functional (copy-based) and return a new node with
9 Copyright (C) 2009, 2010, 2011, 2012, 2013 Google Inc.
11 This program is free software; you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation; either version 2 of the License, or
14 (at your option) any later version.
16 This program is distributed in the hope that it will be useful, but
17 WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
19 General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with this program; if not, write to the Free Software
23 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
28 module Ganeti.HTools.Node
33 -- ** Finalization after data loading
50 -- * Instance (re)location
63 , conflictingPrimaries
77 import Control.Monad (liftM, liftM2)
78 import qualified Data.Foldable as Foldable
79 import Data.Function (on)
80 import qualified Data.Graph as Graph
81 import qualified Data.IntMap as IntMap
82 import Data.List hiding (group)
83 import qualified Data.Map as Map
84 import Data.Ord (comparing)
85 import Text.Printf (printf)
87 import qualified Ganeti.HTools.Container as Container
88 import qualified Ganeti.HTools.Instance as Instance
89 import qualified Ganeti.HTools.PeerMap as P
91 import Ganeti.BasicTypes
92 import qualified Ganeti.HTools.Types as T
94 -- * Type declarations
96 -- | The tag map type.
97 type TagMap = Map.Map String Int
101 { name :: String -- ^ The node name
102 , alias :: String -- ^ The shortened name (for display purposes)
103 , tMem :: Double -- ^ Total memory (MiB)
104 , nMem :: Int -- ^ Node memory (MiB)
105 , fMem :: Int -- ^ Free memory (MiB)
106 , xMem :: Int -- ^ Unaccounted memory (MiB)
107 , tDsk :: Double -- ^ Total disk space (MiB)
108 , fDsk :: Int -- ^ Free disk space (MiB)
109 , tCpu :: Double -- ^ Total CPU count
110 , uCpu :: Int -- ^ Used VCPU count
111 , spindleCount :: Int -- ^ Node spindles (spindle_count node parameter)
112 , pList :: [T.Idx] -- ^ List of primary instance indices
113 , sList :: [T.Idx] -- ^ List of secondary instance indices
114 , idx :: T.Ndx -- ^ Internal index for book-keeping
115 , peers :: P.PeerMap -- ^ Pnode to instance mapping
116 , failN1 :: Bool -- ^ Whether the node has failed n1
117 , rMem :: Int -- ^ Maximum memory needed for failover by
118 -- primaries of this node
119 , pMem :: Double -- ^ Percent of free memory
120 , pDsk :: Double -- ^ Percent of free disk
121 , pRem :: Double -- ^ Percent of reserved memory
122 , pCpu :: Double -- ^ Ratio of virtual to physical CPUs
123 , mDsk :: Double -- ^ Minimum free disk ratio
124 , loDsk :: Int -- ^ Autocomputed from mDsk low disk
126 , hiCpu :: Int -- ^ Autocomputed from mCpu high cpu
128 , hiSpindles :: Double -- ^ Auto-computed from policy spindle_ratio
129 -- and the node spindle count
130 , instSpindles :: Double -- ^ Spindles used by instances
131 , offline :: Bool -- ^ Whether the node should not be used for
132 -- allocations and skipped from score
134 , isMaster :: Bool -- ^ Whether the node is the master node
135 , utilPool :: T.DynUtil -- ^ Total utilisation capacity
136 , utilLoad :: T.DynUtil -- ^ Sum of instance utilisation
137 , pTags :: TagMap -- ^ Primary instance exclusion tags and their count
138 , group :: T.Gdx -- ^ The node's group (index)
139 , iPolicy :: T.IPolicy -- ^ The instance policy (of the node's group)
140 } deriving (Show, Eq)
142 instance T.Element Node where
147 allNames n = [name n, alias n]
149 -- | A simple name for the int, node association list.
150 type AssocList = [(T.Ndx, Node)]
152 -- | A simple name for a node map.
153 type List = Container.Container Node
155 -- | A simple name for an allocation element (here just for logistic
157 type AllocElement = (List, Instance.Instance, [Node], T.Score)
159 -- | Constant node index for a non-moveable instance.
163 -- * Helper functions
165 -- | Add a tag to a tagmap.
166 addTag :: TagMap -> String -> TagMap
167 addTag t s = Map.insertWith (+) s 1 t
169 -- | Add multiple tags.
170 addTags :: TagMap -> [String] -> TagMap
171 addTags = foldl' addTag
173 -- | Adjust or delete a tag from a tagmap.
174 delTag :: TagMap -> String -> TagMap
175 delTag t s = Map.update (\v -> if v > 1
180 -- | Remove multiple tags.
181 delTags :: TagMap -> [String] -> TagMap
182 delTags = foldl' delTag
184 -- | Check if we can add a list of tags to a tagmap.
185 rejectAddTags :: TagMap -> [String] -> Bool
186 rejectAddTags t = any (`Map.member` t)
188 -- | Check how many primary instances have conflicting tags. The
189 -- algorithm to compute this is to sum the count of all tags, then
190 -- subtract the size of the tag map (since each tag has at least one,
191 -- non-conflicting instance); this is equivalent to summing the
192 -- values in the tag map minus one.
193 conflictingPrimaries :: Node -> Int
194 conflictingPrimaries (Node { pTags = t }) = Foldable.sum t - Map.size t
196 -- | Helper function to increment a base value depending on the passed
198 incIf :: (Num a) => Bool -> a -> a -> a
199 incIf True base delta = base + delta
200 incIf False base _ = base
202 -- | Helper function to decrement a base value depending on the passed
204 decIf :: (Num a) => Bool -> a -> a -> a
205 decIf True base delta = base - delta
206 decIf False base _ = base
208 -- * Initialization functions
210 -- | Create a new node.
212 -- The index and the peers maps are empty, and will be need to be
213 -- update later via the 'setIdx' and 'buildPeers' functions.
214 create :: String -> Double -> Int -> Int -> Double
215 -> Int -> Double -> Bool -> Int -> T.Gdx -> Node
216 create name_init mem_t_init mem_n_init mem_f_init
217 dsk_t_init dsk_f_init cpu_t_init offline_init spindles_init
219 Node { name = name_init
227 , spindleCount = spindles_init
235 , pMem = fromIntegral mem_f_init / mem_t_init
236 , pDsk = computePDsk dsk_f_init dsk_t_init
239 , offline = offline_init
242 , mDsk = T.defReservedDiskRatio
243 , loDsk = mDskToloDsk T.defReservedDiskRatio dsk_t_init
244 , hiCpu = mCpuTohiCpu (T.iPolicyVcpuRatio T.defIPolicy) cpu_t_init
245 , hiSpindles = computeHiSpindles (T.iPolicySpindleRatio T.defIPolicy)
248 , utilPool = T.baseUtil
249 , utilLoad = T.zeroUtil
252 , iPolicy = T.defIPolicy
255 -- | Conversion formula from mDsk\/tDsk to loDsk.
256 mDskToloDsk :: Double -> Double -> Int
257 mDskToloDsk mval = floor . (mval *)
259 -- | Conversion formula from mCpu\/tCpu to hiCpu.
260 mCpuTohiCpu :: Double -> Double -> Int
261 mCpuTohiCpu mval = floor . (mval *)
263 -- | Conversiojn formula from spindles and spindle ratio to hiSpindles.
264 computeHiSpindles :: Double -> Int -> Double
265 computeHiSpindles spindle_ratio = (spindle_ratio *) . fromIntegral
267 -- | Changes the index.
269 -- This is used only during the building of the data structures.
270 setIdx :: Node -> T.Ndx -> Node
271 setIdx t i = t {idx = i}
273 -- | Changes the alias.
275 -- This is used only during the building of the data structures.
276 setAlias :: Node -> String -> Node
277 setAlias t s = t { alias = s }
279 -- | Sets the offline attribute.
280 setOffline :: Node -> Bool -> Node
281 setOffline t val = t { offline = val }
283 -- | Sets the master attribute
284 setMaster :: Node -> Bool -> Node
285 setMaster t val = t { isMaster = val }
287 -- | Sets the unnaccounted memory.
288 setXmem :: Node -> Int -> Node
289 setXmem t val = t { xMem = val }
291 -- | Sets the max disk usage ratio.
292 setMdsk :: Node -> Double -> Node
293 setMdsk t val = t { mDsk = val, loDsk = mDskToloDsk val (tDsk t) }
295 -- | Sets the max cpu usage ratio. This will update the node's
296 -- ipolicy, losing sharing (but it should be a seldomly done operation).
297 setMcpu :: Node -> Double -> Node
299 let new_ipol = (iPolicy t) { T.iPolicyVcpuRatio = val }
300 in t { hiCpu = mCpuTohiCpu val (tCpu t), iPolicy = new_ipol }
302 -- | Sets the policy.
303 setPolicy :: T.IPolicy -> Node -> Node
306 , hiCpu = mCpuTohiCpu (T.iPolicyVcpuRatio pol) (tCpu node)
307 , hiSpindles = computeHiSpindles (T.iPolicySpindleRatio pol)
311 -- | Computes the maximum reserved memory for peers from a peer map.
312 computeMaxRes :: P.PeerMap -> P.Elem
313 computeMaxRes = P.maxElem
315 -- | Builds the peer map for a given node.
316 buildPeers :: Node -> Instance.List -> Node
319 (\i_idx -> let inst = Container.find i_idx il
320 mem = if Instance.usesSecMem inst
321 then Instance.mem inst
323 in (Instance.pNode inst, mem))
325 pmap = P.accumArray (+) mdata
326 new_rmem = computeMaxRes pmap
327 new_failN1 = fMem t <= new_rmem
328 new_prem = fromIntegral new_rmem / tMem t
329 in t {peers=pmap, failN1 = new_failN1, rMem = new_rmem, pRem = new_prem}
331 -- | Calculate the new spindle usage
332 calcSpindleUse :: Node -> Instance.Instance -> Double
333 calcSpindleUse n i = incIf (Instance.usesLocalStorage i) (instSpindles n)
334 (fromIntegral $ Instance.spindleUse i)
336 -- | Assigns an instance to a node as primary and update the used VCPU
337 -- count, utilisation data and tags map.
338 setPri :: Node -> Instance.Instance -> Node
339 setPri t inst = t { pList = Instance.idx inst:pList t
341 , pCpu = fromIntegral new_count / tCpu t
342 , utilLoad = utilLoad t `T.addUtil` Instance.util inst
343 , pTags = addTags (pTags t) (Instance.exclTags inst)
344 , instSpindles = calcSpindleUse t inst
346 where new_count = Instance.applyIfOnline inst (+ Instance.vcpus inst)
349 -- | Assigns an instance to a node as secondary without other updates.
350 setSec :: Node -> Instance.Instance -> Node
351 setSec t inst = t { sList = Instance.idx inst:sList t
352 , utilLoad = old_load { T.dskWeight = T.dskWeight old_load +
353 T.dskWeight (Instance.util inst) }
354 , instSpindles = calcSpindleUse t inst
356 where old_load = utilLoad t
358 -- | Computes the new 'pDsk' value, handling nodes without local disk
359 -- storage (we consider all their disk used).
360 computePDsk :: Int -> Double -> Double
362 computePDsk used total = fromIntegral used / total
364 -- * Update functions
366 -- | Sets the free memory.
367 setFmem :: Node -> Int -> Node
369 let new_n1 = new_mem < rMem t
370 new_mp = fromIntegral new_mem / tMem t
371 in t { fMem = new_mem, failN1 = new_n1, pMem = new_mp }
373 -- | Removes a primary instance.
374 removePri :: Node -> Instance.Instance -> Node
376 let iname = Instance.idx inst
377 i_online = Instance.notOffline inst
378 uses_disk = Instance.usesLocalStorage inst
379 new_plist = delete iname (pList t)
380 new_mem = incIf i_online (fMem t) (Instance.mem inst)
381 new_dsk = incIf uses_disk (fDsk t) (Instance.dsk inst)
382 new_spindles = decIf uses_disk (instSpindles t) 1
383 new_mp = fromIntegral new_mem / tMem t
384 new_dp = computePDsk new_dsk (tDsk t)
385 new_failn1 = new_mem <= rMem t
386 new_ucpu = decIf i_online (uCpu t) (Instance.vcpus inst)
387 new_rcpu = fromIntegral new_ucpu / tCpu t
388 new_load = utilLoad t `T.subUtil` Instance.util inst
389 in t { pList = new_plist, fMem = new_mem, fDsk = new_dsk
390 , failN1 = new_failn1, pMem = new_mp, pDsk = new_dp
391 , uCpu = new_ucpu, pCpu = new_rcpu, utilLoad = new_load
392 , pTags = delTags (pTags t) (Instance.exclTags inst)
393 , instSpindles = new_spindles
396 -- | Removes a secondary instance.
397 removeSec :: Node -> Instance.Instance -> Node
399 let iname = Instance.idx inst
400 uses_disk = Instance.usesLocalStorage inst
402 pnode = Instance.pNode inst
403 new_slist = delete iname (sList t)
404 new_dsk = incIf uses_disk cur_dsk (Instance.dsk inst)
405 new_spindles = decIf uses_disk (instSpindles t) 1
407 old_peem = P.find pnode old_peers
408 new_peem = decIf (Instance.usesSecMem inst) old_peem (Instance.mem inst)
409 new_peers = if new_peem > 0
410 then P.add pnode new_peem old_peers
411 else P.remove pnode old_peers
413 new_rmem = if old_peem < old_rmem
415 else computeMaxRes new_peers
416 new_prem = fromIntegral new_rmem / tMem t
417 new_failn1 = fMem t <= new_rmem
418 new_dp = computePDsk new_dsk (tDsk t)
419 old_load = utilLoad t
420 new_load = old_load { T.dskWeight = T.dskWeight old_load -
421 T.dskWeight (Instance.util inst) }
422 in t { sList = new_slist, fDsk = new_dsk, peers = new_peers
423 , failN1 = new_failn1, rMem = new_rmem, pDsk = new_dp
424 , pRem = new_prem, utilLoad = new_load
425 , instSpindles = new_spindles
428 -- | Adds a primary instance (basic version).
429 addPri :: Node -> Instance.Instance -> T.OpResult Node
430 addPri = addPriEx False
432 -- | Adds a primary instance (extended version).
433 addPriEx :: Bool -- ^ Whether to override the N+1 and
434 -- other /soft/ checks, useful if we
435 -- come from a worse status
437 -> Node -- ^ The target node
438 -> Instance.Instance -- ^ The instance to add
439 -> T.OpResult Node -- ^ The result of the operation,
440 -- either the new version of the node
442 addPriEx force t inst =
443 let iname = Instance.idx inst
444 i_online = Instance.notOffline inst
445 uses_disk = Instance.usesLocalStorage inst
447 new_mem = decIf i_online (fMem t) (Instance.mem inst)
448 new_dsk = decIf uses_disk cur_dsk (Instance.dsk inst)
449 new_spindles = incIf uses_disk (instSpindles t) 1
450 new_failn1 = new_mem <= rMem t
451 new_ucpu = incIf i_online (uCpu t) (Instance.vcpus inst)
452 new_pcpu = fromIntegral new_ucpu / tCpu t
453 new_dp = computePDsk new_dsk (tDsk t)
454 l_cpu = T.iPolicyVcpuRatio $ iPolicy t
455 new_load = utilLoad t `T.addUtil` Instance.util inst
456 inst_tags = Instance.exclTags inst
460 _ | new_mem <= 0 -> Bad T.FailMem
461 | uses_disk && new_dsk <= 0 -> Bad T.FailDisk
462 | uses_disk && mDsk t > new_dp && strict -> Bad T.FailDisk
463 | uses_disk && new_spindles > hiSpindles t
464 && strict -> Bad T.FailDisk
465 | new_failn1 && not (failN1 t) && strict -> Bad T.FailMem
466 | l_cpu >= 0 && l_cpu < new_pcpu && strict -> Bad T.FailCPU
467 | rejectAddTags old_tags inst_tags -> Bad T.FailTags
469 let new_plist = iname:pList t
470 new_mp = fromIntegral new_mem / tMem t
471 r = t { pList = new_plist, fMem = new_mem, fDsk = new_dsk
472 , failN1 = new_failn1, pMem = new_mp, pDsk = new_dp
473 , uCpu = new_ucpu, pCpu = new_pcpu
474 , utilLoad = new_load
475 , pTags = addTags old_tags inst_tags
476 , instSpindles = new_spindles
480 -- | Adds a secondary instance (basic version).
481 addSec :: Node -> Instance.Instance -> T.Ndx -> T.OpResult Node
482 addSec = addSecEx False
484 -- | Adds a secondary instance (extended version).
485 addSecEx :: Bool -> Node -> Instance.Instance -> T.Ndx -> T.OpResult Node
486 addSecEx force t inst pdx =
487 let iname = Instance.idx inst
490 new_dsk = fDsk t - Instance.dsk inst
491 new_spindles = instSpindles t + 1
492 secondary_needed_mem = if Instance.usesSecMem inst
493 then Instance.mem inst
495 new_peem = P.find pdx old_peers + secondary_needed_mem
496 new_peers = P.add pdx new_peem old_peers
497 new_rmem = max (rMem t) new_peem
498 new_prem = fromIntegral new_rmem / tMem t
499 new_failn1 = old_mem <= new_rmem
500 new_dp = computePDsk new_dsk (tDsk t)
501 old_load = utilLoad t
502 new_load = old_load { T.dskWeight = T.dskWeight old_load +
503 T.dskWeight (Instance.util inst) }
506 _ | not (Instance.hasSecondary inst) -> Bad T.FailDisk
507 | new_dsk <= 0 -> Bad T.FailDisk
508 | mDsk t > new_dp && strict -> Bad T.FailDisk
509 | new_spindles > hiSpindles t && strict -> Bad T.FailDisk
510 | secondary_needed_mem >= old_mem && strict -> Bad T.FailMem
511 | new_failn1 && not (failN1 t) && strict -> Bad T.FailMem
513 let new_slist = iname:sList t
514 r = t { sList = new_slist, fDsk = new_dsk
515 , peers = new_peers, failN1 = new_failn1
516 , rMem = new_rmem, pDsk = new_dp
517 , pRem = new_prem, utilLoad = new_load
518 , instSpindles = new_spindles
524 -- | Computes the amount of available disk on a given node.
525 availDisk :: Node -> Int
533 -- | Computes the amount of used disk on a given node.
535 iDsk t = truncate (tDsk t) - fDsk t
537 -- | Computes the amount of available memory on a given node.
538 availMem :: Node -> Int
546 -- | Computes the amount of available memory on a given node.
547 availCpu :: Node -> Int
555 -- | The memory used by instances on a given node.
557 iMem t = truncate (tMem t) - nMem t - xMem t - fMem t
559 -- * Node graph functions
560 -- These functions do the transformations needed so that nodes can be
561 -- represented as a graph connected by the instances that are replicated
564 -- * Making of a Graph from a node/instance list
566 -- | Transform an instance into a list of edges on the node graph
567 instanceToEdges :: Instance.Instance -> [Graph.Edge]
569 | Instance.hasSecondary i = [(pnode,snode), (snode,pnode)]
571 where pnode = Instance.pNode i
572 snode = Instance.sNode i
574 -- | Transform the list of instances into list of destination edges
575 instancesToEdges :: Instance.List -> [Graph.Edge]
576 instancesToEdges = concatMap instanceToEdges . Container.elems
578 -- | Transform the list of nodes into vertices bounds.
579 -- Returns Nothing is the list is empty.
580 nodesToBounds :: List -> Maybe Graph.Bounds
581 nodesToBounds nl = liftM2 (,) nmin nmax
582 where nmin = fmap (fst . fst) (IntMap.minViewWithKey nl)
583 nmax = fmap (fst . fst) (IntMap.maxViewWithKey nl)
585 -- | Transform a Node + Instance list into a NodeGraph type.
586 -- Returns Nothing if the node list is empty.
587 mkNodeGraph :: List -> Instance.List -> Maybe Graph.Graph
589 liftM (`Graph.buildG` instancesToEdges il) (nodesToBounds nl)
591 -- * Display functions
593 -- | Return a field for a given node.
594 showField :: Node -- ^ Node which we're querying
595 -> String -- ^ Field name
596 -> String -- ^ Field value as string
599 "idx" -> printf "%4d" $ idx t
602 "status" -> case () of
606 "tmem" -> printf "%5.0f" $ tMem t
607 "nmem" -> printf "%5d" $ nMem t
608 "xmem" -> printf "%5d" $ xMem t
609 "fmem" -> printf "%5d" $ fMem t
610 "imem" -> printf "%5d" $ iMem t
611 "rmem" -> printf "%5d" $ rMem t
612 "amem" -> printf "%5d" $ fMem t - rMem t
613 "tdsk" -> printf "%5.0f" $ tDsk t / 1024
614 "fdsk" -> printf "%5d" $ fDsk t `div` 1024
615 "tcpu" -> printf "%4.0f" $ tCpu t
616 "ucpu" -> printf "%4d" $ uCpu t
617 "pcnt" -> printf "%3d" $ length (pList t)
618 "scnt" -> printf "%3d" $ length (sList t)
619 "plist" -> show $ pList t
620 "slist" -> show $ sList t
621 "pfmem" -> printf "%6.4f" $ pMem t
622 "pfdsk" -> printf "%6.4f" $ pDsk t
623 "rcpu" -> printf "%5.2f" $ pCpu t
624 "cload" -> printf "%5.3f" uC
625 "mload" -> printf "%5.3f" uM
626 "dload" -> printf "%5.3f" uD
627 "nload" -> printf "%5.3f" uN
628 "ptags" -> intercalate "," . map (uncurry (printf "%s=%d")) .
630 "peermap" -> show $ peers t
631 "spindle_count" -> show $ spindleCount t
632 "hi_spindles" -> show $ hiSpindles t
633 "inst_spindles" -> show $ instSpindles t
636 T.DynUtil { T.cpuWeight = uC, T.memWeight = uM,
637 T.dskWeight = uD, T.netWeight = uN } = utilLoad t
639 -- | Returns the header and numeric propery of a field.
640 showHeader :: String -> (String, Bool)
643 "idx" -> ("Index", True)
644 "name" -> ("Name", False)
645 "fqdn" -> ("Name", False)
646 "status" -> ("F", False)
647 "tmem" -> ("t_mem", True)
648 "nmem" -> ("n_mem", True)
649 "xmem" -> ("x_mem", True)
650 "fmem" -> ("f_mem", True)
651 "imem" -> ("i_mem", True)
652 "rmem" -> ("r_mem", True)
653 "amem" -> ("a_mem", True)
654 "tdsk" -> ("t_dsk", True)
655 "fdsk" -> ("f_dsk", True)
656 "tcpu" -> ("pcpu", True)
657 "ucpu" -> ("vcpu", True)
658 "pcnt" -> ("pcnt", True)
659 "scnt" -> ("scnt", True)
660 "plist" -> ("primaries", True)
661 "slist" -> ("secondaries", True)
662 "pfmem" -> ("p_fmem", True)
663 "pfdsk" -> ("p_fdsk", True)
664 "rcpu" -> ("r_cpu", True)
665 "cload" -> ("lCpu", True)
666 "mload" -> ("lMem", True)
667 "dload" -> ("lDsk", True)
668 "nload" -> ("lNet", True)
669 "ptags" -> ("PrimaryTags", False)
670 "peermap" -> ("PeerMap", False)
671 "spindle_count" -> ("NodeSpindles", True)
672 "hi_spindles" -> ("MaxSpindles", True)
673 "inst_spindles" -> ("InstSpindles", True)
674 -- TODO: add node fields (group.uuid, group)
675 _ -> (T.unknownField, False)
677 -- | String converter for the node list functionality.
678 list :: [String] -> Node -> [String]
679 list fields t = map (showField t) fields
681 -- | Constant holding the fields we're displaying by default.
682 defaultFields :: [String]
684 [ "status", "name", "tmem", "nmem", "imem", "xmem", "fmem"
685 , "rmem", "tdsk", "fdsk", "tcpu", "ucpu", "pcnt", "scnt"
686 , "pfmem", "pfdsk", "rcpu"
687 , "cload", "mload", "dload", "nload" ]
689 {-# ANN computeGroups "HLint: ignore Use alternative" #-}
690 -- | Split a list of nodes into a list of (node group UUID, list of
691 -- associated nodes).
692 computeGroups :: [Node] -> [(T.Gdx, [Node])]
693 computeGroups nodes =
694 let nodes' = sortBy (comparing group) nodes
695 nodes'' = groupBy ((==) `on` group) nodes'
696 -- use of head here is OK, since groupBy returns non-empty lists; if
697 -- you remove groupBy, also remove use of head
698 in map (\nl -> (group (head nl), nl)) nodes''